cabloy 5.1.216 → 5.1.217

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 (81) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/playwright-e2e.yml +163 -13
  3. package/.github/workflows/vona-test-sqlite3.yml +77 -7
  4. package/CHANGELOG.md +12 -0
  5. package/CONTRIBUTING.md +4 -2
  6. package/package.json +1 -1
  7. package/repo-docs/frontend/scripts.md +2 -0
  8. package/repo-docs/reference/repo-scripts.md +27 -1
  9. package/repo-e2e/scripts/e2e.test.ts +1 -1
  10. package/repo-e2e/scripts/managedTest.test.ts +115 -3
  11. package/repo-e2e/scripts/managedTest.ts +69 -20
  12. package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
  13. package/repo-e2e/scripts/runE2e.test.ts +109 -0
  14. package/repo-e2e/scripts/runE2e.ts +58 -39
  15. package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
  16. package/repo-e2e/scripts/runE2eArgs.ts +5 -0
  17. package/repo-e2e/specs/a-commerce.spec.ts +10 -2
  18. package/repo-e2e/specs/home-user-account.spec.ts +4 -0
  19. package/repo-e2e/specs/markdown.spec.ts +14 -3
  20. package/scripts/generateOpenapiIsolated.test.ts +5 -1
  21. package/scripts/runIsolatedTest.test.ts +403 -36
  22. package/scripts/runIsolatedTest.ts +139 -12
  23. package/scripts/testBrowserGuardianWindows.test.ts +327 -0
  24. package/scripts/testClaimCleanup.test.ts +25 -9
  25. package/scripts/testClaimCleanup.ts +14 -45
  26. package/scripts/testClaimProcesses.test.ts +81 -2
  27. package/scripts/testClaimProcesses.ts +46 -10
  28. package/scripts/testProcessGuardianWindows.test.ts +602 -0
  29. package/scripts/testProcessGuardianWindows.ts +155 -0
  30. package/vona/packages-cli/cli/package.json +1 -1
  31. package/vona/packages-cli/cli-set-api/package.json +1 -1
  32. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
  33. package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
  34. package/vona/packages-vona/vona/package.json +1 -1
  35. package/vona/packages-vona/vona-core/package.json +1 -1
  36. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
  37. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
  38. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
  39. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
  40. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
  41. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
  42. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
  43. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
  44. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
  45. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
  46. package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
  47. package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
  48. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
  49. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
  50. package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
  51. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
  52. package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
  53. package/vona/packages-vona/vona-mock/package.json +1 -1
  54. package/vona/pnpm-lock.yaml +22 -22
  55. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  56. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  57. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  58. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  59. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
  60. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  61. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
  62. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
  63. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
  64. package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
  65. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  66. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
  67. package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
  68. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  69. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
  70. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  71. package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
  72. package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
  73. package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
  74. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  75. package/zova/packages-cli/cli/package.json +2 -2
  76. package/zova/packages-cli/cli-set-front/package.json +2 -2
  77. package/zova/packages-utils/zova-vite/package.json +1 -1
  78. package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
  79. package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
  80. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
  81. package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+
5
+ const useAppUrl = new URL('./useApp.ts', import.meta.url).href;
6
+
7
+ for (const platform of ['win32', 'linux', 'darwin']) {
8
+ test(`general apps use the single-app entry and await close on ${platform}`, () => {
9
+ const source = `
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { registerHooks } from 'node:module';
15
+ const useAppUrl = ${JSON.stringify(useAppUrl)};
16
+ const projectPath = mkdtempSync(path.join(tmpdir(), 'vona-general-app-'));
17
+ const appSource =
18
+ 'export async function createSingleApp() {' +
19
+ ' if (globalThis.failure) throw globalThis.failure;' +
20
+ ' globalThis.factoryCalls++;' +
21
+ ' return globalThis.__app__ = { meta: { async close() {' +
22
+ ' await new Promise(resolve => setTimeout(resolve, 10));' +
23
+ ' globalThis.closed = true;' +
24
+ ' } } };' +
25
+ '}';
26
+ try {
27
+ writeFileSync(path.join(projectPath, 'package.json'), '{"type":"module"}');
28
+ for (const flavor of ['ci', 'other']) {
29
+ const directory = path.join(projectPath, 'dist', flavor);
30
+ mkdirSync(directory, { recursive: true });
31
+ writeFileSync(path.join(directory, 'app.js'), appSource);
32
+ writeFileSync(path.join(directory, 'bootstrap.js'), 'throw new Error("General apps must not bootstrap a server");');
33
+ }
34
+ const generatedRoot = path.join(projectPath, '.vona');
35
+ mkdirSync(generatedRoot);
36
+ writeFileSync(path.join(generatedRoot, 'app.ts'), appSource);
37
+ const mocks = {
38
+ '@cabloy/utils': 'export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
39
+ '../utils/util.ts': 'import { pathToFileURL } from "node:url"; export const pathToHref = file => pathToFileURL(file).href;',
40
+ '../core/testIsolation.ts': 'export function resolveVonaLocalPaths(projectPath, mode) { globalThis.resolvedMode = mode; return { generatedRoot: globalThis.generatedRoot }; }',
41
+ };
42
+ registerHooks({
43
+ resolve(specifier, context, nextResolve) {
44
+ if (context.parentURL === useAppUrl && mocks[specifier]) {
45
+ return { url: 'data:text/javascript,' + encodeURIComponent(mocks[specifier]), shortCircuit: true };
46
+ }
47
+ return nextResolve(specifier, context);
48
+ },
49
+ });
50
+ Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} });
51
+ globalThis.generatedRoot = generatedRoot;
52
+ globalThis.factoryCalls = 0;
53
+ const { createGeneralApp, closeApp, useApp } = await import(useAppUrl);
54
+ for (const mode of ['prod', 'dev', 'test']) {
55
+ globalThis.closed = false;
56
+ const app = await createGeneralApp(projectPath, { META_MODE: mode, META_FLAVOR: 'ci' });
57
+ assert.equal(app, useApp());
58
+ assert.equal(globalThis.factoryCalls, 1);
59
+ assert.equal(globalThis.closed, false);
60
+ if (mode !== 'prod') assert.equal(globalThis.resolvedMode, mode);
61
+ await closeApp();
62
+ assert.equal(globalThis.closed, true, 'close must await application cleanup');
63
+ assert.equal(useApp(), undefined);
64
+ globalThis.factoryCalls = 0;
65
+ }
66
+ const failure = globalThis.failure = new Error('single-app startup failed');
67
+ await assert.rejects(
68
+ createGeneralApp(projectPath, { META_MODE: 'prod', META_FLAVOR: 'other' }),
69
+ error => error === failure,
70
+ );
71
+ assert.equal(useApp(), undefined);
72
+ } finally {
73
+ rmSync(projectPath, { recursive: true, force: true });
74
+ }
75
+ `;
76
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
77
+ encoding: 'utf8',
78
+ timeout: 5_000,
79
+ });
80
+ assert.ifError(result.error);
81
+ assert.equal(result.signal, null, result.stderr);
82
+ assert.equal(result.status, 0, result.stderr);
83
+ });
84
+ }
@@ -35,9 +35,9 @@ export async function createGeneralApp(
35
35
  }
36
36
  }
37
37
  if (process.env.META_MODE === 'prod') {
38
- const testFile = path.join(projectPath, `dist/${process.env.META_FLAVOR}/bootstrap.js`);
38
+ const testFile = path.join(projectPath, `dist/${process.env.META_FLAVOR}/app.js`);
39
39
  const testInstance = await import(pathToHref(testFile));
40
- return await testInstance.appBootstrap;
40
+ return await testInstance.createSingleApp();
41
41
  } else {
42
42
  const testFile = path.join(
43
43
  resolveVonaLocalPaths(projectPath, process.env.META_MODE).generatedRoot,
@@ -122,7 +122,7 @@ export class AppUtil extends BeanSimple {
122
122
  const globalPrefix = '/api/static';
123
123
  if (!staticPath) staticPath = '';
124
124
  // safe
125
- staticPath = path.normalize(staticPath);
125
+ staticPath = path.posix.normalize(staticPath.replace(/\\/g, '/'));
126
126
  if (staticPath.includes('..')) throw new Error(`unsafe path: ${staticPath}`);
127
127
  // ignore globalPrefix
128
128
  if (staticPath.startsWith('//')) return staticPath.substring(1);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-mock",
3
- "version": "6.1.46",
3
+ "version": "6.1.47",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona mock",
6
6
  "keywords": [
@@ -573,7 +573,7 @@ importers:
573
573
  specifier: npm:@cabloy/zod@4.3.8
574
574
  version: '@cabloy/zod@4.3.8'
575
575
  zova:
576
- specifier: ^5.1.170
576
+ specifier: ^5.1.171
577
577
  version: 5.1.171(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
578
578
  zova-jsx:
579
579
  specifier: ^1.1.92
@@ -606,8 +606,8 @@ importers:
606
606
  specifier: ^5.1.38
607
607
  version: 5.1.38(vue@3.5.43(typescript@5.9.3))
608
608
  zova-module-a-openapi:
609
- specifier: file:/tmp/zova-module-a-openapi-5.1.56.tgz
610
- version: file:../../../../../tmp/zova-module-a-openapi-5.1.56.tgz
609
+ specifier: ^5.1.57
610
+ version: 5.1.57
611
611
  zova-module-a-pay:
612
612
  specifier: ^5.0.9
613
613
  version: 5.0.9
@@ -741,8 +741,8 @@ importers:
741
741
  specifier: npm:@cabloy/zod@4.3.8
742
742
  version: '@cabloy/zod@4.3.8'
743
743
  zova:
744
- specifier: ^5.1.167
745
- version: 5.1.167(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
744
+ specifier: ^5.1.171
745
+ version: 5.1.171(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
746
746
  zova-jsx:
747
747
  specifier: ^1.1.92
748
748
  version: 1.1.92(typescript@5.9.3)
@@ -759,8 +759,8 @@ importers:
759
759
  specifier: ^5.1.35
760
760
  version: 5.1.35
761
761
  zova-module-a-form:
762
- specifier: ^5.1.51
763
- version: 5.1.51(vue@3.5.43(typescript@5.9.3))
762
+ specifier: ^5.1.54
763
+ version: 5.1.54(vue@3.5.43(typescript@5.9.3))
764
764
  zova-module-a-icon:
765
765
  specifier: ^5.1.27
766
766
  version: 5.1.27
@@ -771,20 +771,20 @@ importers:
771
771
  specifier: ^5.0.3
772
772
  version: 5.0.3
773
773
  zova-module-a-model:
774
- specifier: ^5.1.37
775
- version: 5.1.37(vue@3.5.43(typescript@5.9.3))
774
+ specifier: ^5.1.38
775
+ version: 5.1.38(vue@3.5.43(typescript@5.9.3))
776
776
  zova-module-a-openapi:
777
- specifier: ^5.1.55
778
- version: 5.1.55
777
+ specifier: ^5.1.57
778
+ version: 5.1.57
779
779
  zova-module-a-pay:
780
780
  specifier: ^5.0.9
781
781
  version: 5.0.9
782
782
  zova-module-a-router:
783
- specifier: ^5.1.41
784
- version: 5.1.41
783
+ specifier: ^5.1.42
784
+ version: 5.1.42
785
785
  zova-module-a-routertabs:
786
- specifier: ^5.1.34
787
- version: 5.1.34
786
+ specifier: ^5.1.35
787
+ version: 5.1.35
788
788
  zova-module-a-ssr:
789
789
  specifier: ^5.1.40
790
790
  version: 5.1.40
@@ -798,8 +798,8 @@ importers:
798
798
  specifier: ^5.0.5
799
799
  version: 5.0.5
800
800
  zova-module-rest-resource:
801
- specifier: ^5.1.55
802
- version: 5.1.55
801
+ specifier: ^5.1.56
802
+ version: 5.1.56
803
803
 
804
804
  .zova-rest/cabloy-commerce-admin:
805
805
  dependencies:
@@ -15300,16 +15300,16 @@ snapshots:
15300
15300
  zova-module-a-behaviors: 5.1.22
15301
15301
  zova-module-a-command: 5.1.35
15302
15302
  zova-module-a-fetch: 5.1.25
15303
- zova-module-a-form: 5.1.51(vue@3.5.43(typescript@5.9.3))
15303
+ zova-module-a-form: 5.1.54(vue@3.5.43(typescript@5.9.3))
15304
15304
  zova-module-a-icon: 5.1.27
15305
15305
  zova-module-a-interceptor: 5.1.31
15306
15306
  zova-module-a-logger: 5.1.28
15307
15307
  zova-module-a-meta: 5.1.22
15308
- zova-module-a-model: 5.1.37(vue@3.5.43(typescript@5.9.3))
15309
- zova-module-a-openapi: 5.1.55
15310
- zova-module-a-router: 5.1.41
15308
+ zova-module-a-model: 5.1.38(vue@3.5.43(typescript@5.9.3))
15309
+ zova-module-a-openapi: 5.1.57
15310
+ zova-module-a-router: 5.1.42
15311
15311
  zova-module-a-routerstack: 5.1.29
15312
- zova-module-a-routertabs: 5.1.34
15312
+ zova-module-a-routertabs: 5.1.35
15313
15313
  zova-module-a-ssr: 5.1.40
15314
15314
  zova-module-a-ssrhmr: 5.1.23
15315
15315
  zova-module-a-ssrserver: 5.1.26
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-rbac",
3
- "version": "5.0.11",
3
+ "version": "5.0.12",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.62",
3
+ "version": "5.1.63",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -10,7 +10,7 @@
10
10
  "vona-module-a-cabloy": "workspace:^",
11
11
  "vona-module-a-layoutprofile": "^5.0.2",
12
12
  "vona-module-a-markdown": "^5.0.3",
13
- "vona-module-a-rbac": "^5.0.11",
13
+ "vona-module-a-rbac": "^5.0.12",
14
14
  "vona-module-a-socket": "workspace:^",
15
15
  "vona-module-a-ssr": "workspace:^",
16
16
  "vona-module-a-ssrhmr": "workspace:^"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-core",
3
- "version": "5.1.60",
3
+ "version": "5.1.61",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-executor",
3
- "version": "5.1.17",
3
+ "version": "5.1.18",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -4,6 +4,7 @@ import type { IApiPathRecordMethodMap } from 'vona-module-a-web';
4
4
  import { isNil } from '@cabloy/utils';
5
5
  import { BeanBase } from 'vona';
6
6
  import { Bean } from 'vona-module-a-bean';
7
+ import { ServiceTransactionAsyncLocalStorage } from 'vona-module-a-orm';
7
8
 
8
9
  import type { INewCtxOptions, IPerformActionOptions } from '../types/executor.ts';
9
10
 
@@ -113,7 +114,7 @@ export class BeanExecutor extends BeanBase {
113
114
  __delegateProperties(ctx, options.extraData);
114
115
  }
115
116
  }
116
- return await fn();
117
+ return await this.bean._getBean(ServiceTransactionAsyncLocalStorage).run(fn, isolate);
117
118
  });
118
119
  }
119
120
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-logger",
3
- "version": "5.1.15",
3
+ "version": "5.1.16",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -22,7 +22,7 @@ export class MiddlewareSystemHttpLog extends BeanBase implements IMiddlewareSyst
22
22
  req.headers,
23
23
  ctx.path,
24
24
  this.app.meta.env.VONA_TEST_RUN_ID,
25
- 'x-vona-test-challenge',
25
+ ['x-vona-test-challenge', 'x-vona-test-stop-nonce', 'x-vona-test-stop-authorization'],
26
26
  );
27
27
  this.$loggerChild('req').http(() => JSON.stringify(req));
28
28
  const profiler = this.$loggerChild('res').startTimer();
@@ -33,7 +33,7 @@ export class MiddlewareSystemHttpLog extends BeanBase implements IMiddlewareSyst
33
33
  ctx.res.getHeaders(),
34
34
  ctx.path,
35
35
  this.app.meta.env.VONA_TEST_RUN_ID,
36
- 'x-vona-test-proof',
36
+ ['x-vona-test-proof', 'x-vona-test-stop-proof'],
37
37
  );
38
38
  const res = {
39
39
  url: ctx.req.url,
@@ -3,6 +3,22 @@ import { test } from 'node:test';
3
3
 
4
4
  import { redactIsolatedReadyHeader } from './redactIsolatedReadyHeader.ts';
5
5
 
6
+ test('redacts isolated shutdown authorization and proof case-insensitively', () => {
7
+ const headers = {
8
+ 'X-Vona-Test-Stop-Authorization': 'authorization-value',
9
+ 'x-vona-test-stop-nonce': 'nonce-value',
10
+ 'X-VONA-TEST-STOP-PROOF': 'proof-value',
11
+ 'content-type': 'application/json',
12
+ };
13
+ const logged = redactIsolatedReadyHeader(headers, '/health/test-stop', 'managed-run', [
14
+ 'x-vona-test-stop-authorization',
15
+ 'x-vona-test-stop-nonce',
16
+ 'x-vona-test-stop-proof',
17
+ ]);
18
+ assert.deepEqual(logged, { 'content-type': 'application/json' });
19
+ assert.equal(headers['X-Vona-Test-Stop-Authorization'], 'authorization-value');
20
+ });
21
+
6
22
  test('redacts isolated readiness challenge and proof without changing live headers', () => {
7
23
  const runId = '20261014010203-a1b2c3d4';
8
24
  const req = { 'x-vona-test-challenge': 'challenge-value', 'accept': 'application/json' };
@@ -2,10 +2,13 @@ export function redactIsolatedReadyHeader<HEADERS extends Record<string, unknown
2
2
  headers: HEADERS,
3
3
  path: string,
4
4
  runId: string | undefined,
5
- header: string,
5
+ header: string | string[],
6
6
  ): HEADERS {
7
- if (path !== '/health/ready' || !runId) return headers;
7
+ if (!['/health/ready', '/health/test-stop'].includes(path) || !runId) return headers;
8
8
  const redacted = { ...headers };
9
- delete redacted[header];
9
+ const names = (Array.isArray(header) ? header : [header]).map(name => name.toLowerCase());
10
+ for (const name of Object.keys(redacted)) {
11
+ if (names.includes(name.toLowerCase())) delete redacted[name];
12
+ }
10
13
  return redacted;
11
14
  }
@@ -0,0 +1,80 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { registerHooks } from 'node:module';
4
+ import { test } from 'node:test';
5
+ import ts from 'typescript';
6
+
7
+ const middlewareUrl = new URL(
8
+ '../src/bean/middlewareSystem.httpLog.ts?fixture=httpLog',
9
+ import.meta.url,
10
+ ).href;
11
+ const hooks = registerHooks({
12
+ resolve(specifier, context, nextResolve) {
13
+ if (context.parentURL === middlewareUrl) {
14
+ const source = {
15
+ 'vona': 'export class BeanBase {}',
16
+ 'vona-module-a-aspect': 'export function MiddlewareSystem() { return () => {}; }',
17
+ 'pino-std-serializers':
18
+ 'export default { req: req => ({ headers: req.headers, url: req.url }) };',
19
+ }[specifier];
20
+ if (source)
21
+ return { url: `data:text/javascript,${encodeURIComponent(source)}`, shortCircuit: true };
22
+ }
23
+ return nextResolve(specifier, context);
24
+ },
25
+ load(url, context, nextLoad) {
26
+ if (url === middlewareUrl) {
27
+ return {
28
+ format: 'module',
29
+ source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
30
+ compilerOptions: {
31
+ experimentalDecorators: true,
32
+ target: ts.ScriptTarget.ESNext,
33
+ module: ts.ModuleKind.ESNext,
34
+ },
35
+ }).outputText,
36
+ shortCircuit: true,
37
+ };
38
+ }
39
+ return nextLoad(url, context);
40
+ },
41
+ });
42
+ const { MiddlewareSystemHttpLog } = await import(middlewareUrl);
43
+ hooks.deregister();
44
+
45
+ for (const path of ['/health/ready', '/health/test-stop']) {
46
+ test(`HTTP logger redacts request authorization and response proof at ${path} without mutating headers`, async () => {
47
+ const headers = {
48
+ 'x-vona-test-challenge': 'secret-challenge',
49
+ 'X-Vona-Test-Stop-Authorization': 'secret-authorization',
50
+ 'x-vona-test-stop-nonce': 'secret-nonce',
51
+ 'accept': 'application/json',
52
+ };
53
+ const responseHeaders = {
54
+ 'x-vona-test-proof': 'secret-readiness',
55
+ 'X-Vona-Test-Stop-Proof': 'secret-ack',
56
+ 'content-type': 'application/json',
57
+ };
58
+ const logs: string[] = [];
59
+ const middleware = Object.create(MiddlewareSystemHttpLog.prototype);
60
+ Object.assign(middleware, {
61
+ app: { meta: { env: { VONA_TEST_RUN_ID: '20261014010203-a1b2c3d4' } } },
62
+ ctx: {
63
+ path,
64
+ req: { url: path, headers },
65
+ res: { statusCode: 200, getHeaders: () => responseHeaders },
66
+ },
67
+ $loggerChild: () => ({
68
+ http: (message: () => string) => logs.push(message()),
69
+ startTimer: () => ({ done: ({ message }: { message: string }) => logs.push(message) }),
70
+ }),
71
+ });
72
+ await middleware.execute({}, async () => {});
73
+ assert.equal(logs.length, 2);
74
+ assert.equal(logs.join('\n').includes('secret-'), false);
75
+ assert.match(logs[0], /application\/json/);
76
+ assert.match(logs[1], /application\/json/);
77
+ assert.equal(headers['X-Vona-Test-Stop-Authorization'], 'secret-authorization');
78
+ assert.equal(responseHeaders['X-Vona-Test-Stop-Proof'], 'secret-ack');
79
+ });
80
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-metrics",
3
- "version": "5.0.4",
3
+ "version": "5.0.5",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -4,10 +4,15 @@ import type {
4
4
  IMiddlewareSystemExecute,
5
5
  } from 'vona-module-a-aspect';
6
6
 
7
- import { createHmac } from 'node:crypto';
8
- import { BeanBase } from 'vona';
7
+ import { createHmac, timingSafeEqual } from 'node:crypto';
8
+ import { assertTestAppIdentity, BeanBase, closeProcessWorker } from 'vona';
9
9
  import { MiddlewareSystem } from 'vona-module-a-aspect';
10
10
 
11
+ // An accepted shutdown consumes this app's authorization before yielding. This also
12
+ // rejects concurrent/replayed requests across request-scoped middleware instances.
13
+ const stoppingApps = new WeakSet<object>();
14
+ const STOP_PATH = '/health/test-stop';
15
+
11
16
  export interface IMiddlewareSystemOptionsHealth extends IDecoratorMiddlewareSystemOptions {}
12
17
 
13
18
  @MiddlewareSystem<IMiddlewareSystemOptionsHealth>({
@@ -16,6 +21,10 @@ export interface IMiddlewareSystemOptionsHealth extends IDecoratorMiddlewareSyst
16
21
  export class MiddlewareSystemHealth extends BeanBase implements IMiddlewareSystemExecute {
17
22
  async execute(_options: IMiddlewareSystemOptionsHealth, next: Next) {
18
23
  const path = this.ctx.path;
24
+ if (path === STOP_PATH) {
25
+ this._testStop();
26
+ return;
27
+ }
19
28
  if (!['/health/live', '/health/ready', '/health/startup'].includes(path)) return next();
20
29
  const health = await this.$scope.metrics.service.health.status(path);
21
30
  this.ctx.status = health.code;
@@ -36,4 +45,66 @@ export class MiddlewareSystemHealth extends BeanBase implements IMiddlewareSyste
36
45
  }
37
46
  this.ctx.body = { status: health.status };
38
47
  }
48
+
49
+ private _testStop() {
50
+ const ctx = this.ctx;
51
+ // Never trust forwarded IP headers, even when the app trusts a proxy.
52
+ const address = ctx.req.socket.remoteAddress;
53
+ const env = this.app.meta.env;
54
+ const key = process.env.VONA_TEST_READY_KEY;
55
+ let runId: string | undefined;
56
+ try {
57
+ runId = assertTestAppIdentity(env.META_MODE, env.VONA_TEST_RUN_ID ?? '', this.app.name);
58
+ } catch {
59
+ // Invalid or ordinary app identities must not expose this operation.
60
+ }
61
+ if (
62
+ !runId ||
63
+ process.env.META_MODE !== 'test' ||
64
+ process.env.VONA_TEST_RUN_ID !== runId ||
65
+ this.app.config.server.workers !== 1 ||
66
+ !key ||
67
+ !/^[a-f0-9]{64}$/.test(key) ||
68
+ !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address ?? '')
69
+ ) {
70
+ ctx.status = 404;
71
+ return;
72
+ }
73
+ const nonce = ctx.get('x-vona-test-stop-nonce');
74
+ const authorization = ctx.get('x-vona-test-stop-authorization');
75
+ if (
76
+ ctx.method !== 'POST' ||
77
+ ctx.req.url !== STOP_PATH ||
78
+ !/^[a-f0-9]{32}$/.test(nonce) ||
79
+ !/^[a-f0-9]{64}$/.test(authorization)
80
+ ) {
81
+ ctx.status = 403;
82
+ return;
83
+ }
84
+ // Readiness signs only a hex challenge. Neither that signing oracle nor the
85
+ // acknowledgment domain can produce a shutdown request authorization.
86
+ const payload = `${runId}\nPOST\n${STOP_PATH}\n${nonce}`;
87
+ const expected = createHmac('sha256', key)
88
+ .update(`vona-test-stop-request-v1\n${payload}`)
89
+ .digest();
90
+ if (!timingSafeEqual(expected, Buffer.from(authorization, 'hex'))) {
91
+ ctx.status = 403;
92
+ return;
93
+ }
94
+ if (stoppingApps.has(this.app)) {
95
+ ctx.status = 409;
96
+ return;
97
+ }
98
+ stoppingApps.add(this.app);
99
+ ctx.status = 200;
100
+ ctx.type = 'application/json';
101
+ ctx.set(
102
+ 'x-vona-test-stop-proof',
103
+ createHmac('sha256', key).update(`vona-test-stop-ack-v1\n${payload}`).digest('hex'),
104
+ );
105
+ ctx.body = { status: 'stopping' };
106
+ // closeApp may close this listener, so flush the authenticated acknowledgment
107
+ // first. A disconnected response is not permission to start closing.
108
+ ctx.res.once('finish', () => void closeProcessWorker());
109
+ }
39
110
  }