cabloy 5.1.216 → 5.1.218

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 (89) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/create-cabloy.yml +137 -0
  3. package/.github/workflows/playwright-e2e.yml +163 -13
  4. package/.github/workflows/vona-test-sqlite3.yml +77 -7
  5. package/CHANGELOG.md +22 -0
  6. package/CONTRIBUTING.md +4 -2
  7. package/package.json +1 -1
  8. package/repo-docs/frontend/scripts.md +2 -0
  9. package/repo-docs/reference/repo-scripts.md +27 -1
  10. package/repo-e2e/scripts/e2e.test.ts +1 -1
  11. package/repo-e2e/scripts/managedTest.test.ts +115 -3
  12. package/repo-e2e/scripts/managedTest.ts +69 -20
  13. package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
  14. package/repo-e2e/scripts/runE2e.test.ts +109 -0
  15. package/repo-e2e/scripts/runE2e.ts +58 -39
  16. package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
  17. package/repo-e2e/scripts/runE2eArgs.ts +5 -0
  18. package/repo-e2e/specs/a-commerce.spec.ts +10 -2
  19. package/repo-e2e/specs/home-user-account.spec.ts +4 -0
  20. package/repo-e2e/specs/markdown.spec.ts +14 -3
  21. package/scripts/generateOpenapiIsolated.test.ts +5 -1
  22. package/scripts/runIsolatedTest.test.ts +403 -36
  23. package/scripts/runIsolatedTest.ts +139 -12
  24. package/scripts/testBrowserGuardianWindows.test.ts +327 -0
  25. package/scripts/testClaimCleanup.test.ts +25 -9
  26. package/scripts/testClaimCleanup.ts +14 -45
  27. package/scripts/testClaimProcesses.test.ts +81 -2
  28. package/scripts/testClaimProcesses.ts +46 -10
  29. package/scripts/testProcessGuardianWindows.test.ts +602 -0
  30. package/scripts/testProcessGuardianWindows.ts +155 -0
  31. package/vona/packages-cli/cli/package.json +1 -1
  32. package/vona/packages-cli/cli-set-api/package.json +1 -1
  33. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
  34. package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
  35. package/vona/packages-vona/vona/package.json +1 -1
  36. package/vona/packages-vona/vona-core/package.json +1 -1
  37. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
  38. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
  39. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
  40. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
  41. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
  42. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
  43. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
  44. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
  45. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
  46. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
  47. package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
  48. package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
  49. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
  50. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
  51. package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
  52. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
  53. package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
  54. package/vona/packages-vona/vona-mock/package.json +1 -1
  55. package/vona/pnpm-lock.yaml +42 -181
  56. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  57. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  58. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  59. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  60. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
  61. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  62. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
  63. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
  64. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
  65. package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
  66. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  67. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
  68. package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
  69. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  70. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
  71. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  72. package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
  73. package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
  74. package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
  75. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  76. package/zova/package.original.json +1 -1
  77. package/zova/packages-cli/cli/package.json +2 -2
  78. package/zova/packages-cli/cli-set-front/package.json +4 -4
  79. package/zova/packages-utils/zova-jsx/package.json +2 -2
  80. package/zova/packages-utils/zova-vite/package.json +2 -2
  81. package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
  82. package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
  83. package/zova/packages-zova/zova/package.json +3 -3
  84. package/zova/packages-zova/zova-core/package.json +2 -2
  85. package/zova/pnpm-lock.yaml +324 -63
  86. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
  87. package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  89. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -1,10 +1,15 @@
1
1
  import { randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { constants, lstatSync } from 'node:fs';
3
- import { mkdir, open, rename, unlink } from 'node:fs/promises';
4
2
  import { createServer } from 'node:http';
5
3
  import path from 'node:path';
6
4
  import { isDeepStrictEqual } from 'node:util';
7
5
 
6
+ import {
7
+ assertTestClaimFilesystemSafePath,
8
+ createTestClaimFile,
9
+ ensureTestClaimPrivateDirectory,
10
+ readTestClaimFile,
11
+ replaceTestClaimFile,
12
+ } from './testClaimFilesystem.ts';
8
13
  import {
9
14
  assertDisposableDatabaseName,
10
15
  getDatabaseTestPrefix,
@@ -15,6 +20,8 @@ import {
15
20
 
16
21
  export const TEST_CLAIM_URL_ENV = 'VONA_TEST_CLAIM_URL';
17
22
  export const TEST_CLAIM_TOKEN_ENV = 'VONA_TEST_CLAIM_TOKEN';
23
+ /** A locator only. Containment still requires the registered guardian's live native verifier. */
24
+ export const TEST_CLAIM_WINDOWS_GUARDIAN_ENV = 'VONA_TEST_WINDOWS_GUARDIAN_ID';
18
25
  export const TEST_CLAIM_DIRECTORY = '.vona-test-claim';
19
26
  export type TestClaimKind = 'unit' | 'cov' | 'e2e' | 'contract';
20
27
  export type TestClaimEnv = Record<string, string | undefined>;
@@ -62,6 +69,8 @@ export interface TestClaimProcess {
62
69
  stage: 'pending' | 'registered' | 'drained';
63
70
  pid?: number;
64
71
  pgid?: number;
72
+ containment?: 'windows-job';
73
+ jobId?: string;
65
74
  startTime?: string;
66
75
  host?: string;
67
76
  profilePath?: string;
@@ -114,20 +123,7 @@ export function getTestClaimCreatedAt(runId: string) {
114
123
  }
115
124
 
116
125
  export function assertTestClaimSafePath(file: string, allowMissing = true) {
117
- let current = path.resolve(file);
118
- while (true) {
119
- try {
120
- const stat = lstatSync(current);
121
- if (stat.isSymbolicLink() || (current !== path.resolve(file) && !stat.isDirectory())) {
122
- throw new Error('Unsafe test claim path');
123
- }
124
- } catch (error) {
125
- if (!allowMissing || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
126
- }
127
- const parent = path.dirname(current);
128
- if (parent === current) break;
129
- current = parent;
130
- }
126
+ assertTestClaimFilesystemSafePath(file, allowMissing);
131
127
  }
132
128
 
133
129
  function record(value: unknown): asserts value is Record<string, any> {
@@ -302,17 +298,43 @@ export function validateTestClaim(value: unknown, claimPath?: string): TestClaim
302
298
  array(m.processes);
303
299
  unique(m.processes.map(item => item.id));
304
300
  for (const proc of m.processes) {
305
- keys(proc, ['id', 'role', 'stage', 'pid', 'pgid', 'startTime', 'host', 'profilePath']);
301
+ keys(proc, [
302
+ 'id',
303
+ 'role',
304
+ 'stage',
305
+ 'pid',
306
+ 'pgid',
307
+ 'containment',
308
+ 'jobId',
309
+ 'startTime',
310
+ 'host',
311
+ 'profilePath',
312
+ ]);
306
313
  label(proc.id);
307
314
  oneOf(proc.role, ['guardian', 'browser', 'cleanup']);
308
315
  if (proc.host !== undefined) text(proc.host);
309
316
  oneOf(proc.stage, ['pending', 'registered', 'drained']);
317
+ if (proc.containment !== undefined) oneOf(proc.containment, ['windows-job']);
318
+ if (proc.containment === 'windows-job') {
319
+ if (proc.pgid !== undefined || proc.role !== 'guardian' || proc.profilePath !== undefined)
320
+ throw new Error('Invalid Windows test claim containment');
321
+ text(proc.jobId);
322
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(proc.jobId))
323
+ throw new Error('Invalid Windows test claim job identity');
324
+ } else if (proc.jobId !== undefined) {
325
+ throw new Error('Windows test claim job needs explicit containment');
326
+ }
310
327
  const hasIdentity =
311
328
  proc.pid !== undefined || proc.pgid !== undefined || proc.startTime !== undefined;
312
329
  if (hasIdentity || proc.stage !== 'pending') {
313
330
  integer(proc.pid, 1);
314
- integer(proc.pgid, 1);
331
+ if (proc.containment !== 'windows-job') integer(proc.pgid, 1);
315
332
  text(proc.startTime);
333
+ if (proc.containment === 'windows-job') {
334
+ if (!/^[1-9]\d{0,19}$/.test(proc.startTime))
335
+ throw new Error('Invalid Windows test claim process creation time');
336
+ text(proc.host);
337
+ }
316
338
  }
317
339
  if (proc.profilePath !== undefined) {
318
340
  text(proc.profilePath);
@@ -329,28 +351,8 @@ export function validateTestClaim(value: unknown, claimPath?: string): TestClaim
329
351
  return m;
330
352
  }
331
353
 
332
- function assertPrivateFile(file: string) {
333
- assertTestClaimSafePath(file, false);
334
- const stat = lstatSync(file);
335
- if (
336
- !stat.isFile() ||
337
- stat.nlink !== 1 ||
338
- (stat.mode & 0o077) !== 0 ||
339
- (process.getuid && stat.uid !== process.getuid())
340
- ) {
341
- throw new Error('Unsafe test claim file');
342
- }
343
- }
344
354
  export async function loadTestClaim(claimPath: string) {
345
- assertPrivateFile(claimPath);
346
- const handle = await open(claimPath, constants.O_RDONLY | constants.O_NOFOLLOW);
347
- try {
348
- const stat = await handle.stat();
349
- if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('Invalid test claim file');
350
- return validateTestClaim(parseTestClaimJson(await handle.readFile('utf8')), claimPath);
351
- } finally {
352
- await handle.close();
353
- }
355
+ return validateTestClaim(parseTestClaimJson(await readTestClaimFile(claimPath)), claimPath);
354
356
  }
355
357
  function parseTestClaimJson(source: string) {
356
358
  // Claim identities stay literal even when @cabloy/json5 revives ISO strings.
@@ -358,14 +360,6 @@ function parseTestClaimJson(source: string) {
358
360
  return this[key];
359
361
  });
360
362
  }
361
- async function syncDirectory(directory: string) {
362
- const handle = await open(directory, constants.O_RDONLY);
363
- try {
364
- await handle.sync();
365
- } finally {
366
- await handle.close();
367
- }
368
- }
369
363
  export async function createTestClaim(
370
364
  projectPath: string,
371
365
  runId: string,
@@ -373,10 +367,7 @@ export async function createTestClaim(
373
367
  owner: TestClaimOwner,
374
368
  ) {
375
369
  const p = getTestClaimPaths(projectPath, runId);
376
- assertTestClaimSafePath(p.claimDirectory);
377
- await mkdir(p.claimDirectory, { mode: 0o700 }).catch(error => {
378
- if (error.code !== 'EEXIST') throw error;
379
- });
370
+ await ensureTestClaimPrivateDirectory(p.claimDirectory);
380
371
  // The directory is shared across runs; an existing directory is valid, never an existing claim.
381
372
  return createClaimFile(p, runId, kind, owner);
382
373
  }
@@ -386,15 +377,6 @@ async function createClaimFile(
386
377
  kind: TestClaimKind,
387
378
  owner: TestClaimOwner,
388
379
  ) {
389
- assertTestClaimSafePath(p.claimDirectory, false);
390
- const directoryStat = lstatSync(p.claimDirectory);
391
- if (
392
- !directoryStat.isDirectory() ||
393
- (directoryStat.mode & 0o077) !== 0 ||
394
- (process.getuid && directoryStat.uid !== process.getuid())
395
- ) {
396
- throw new Error('Unsafe shared test claim directory');
397
- }
398
380
  const manifest: TestClaimManifest = {
399
381
  version: 1,
400
382
  runId,
@@ -413,18 +395,7 @@ async function createClaimFile(
413
395
  cleanup: { processes: false, databases: false, redis: false, locals: false },
414
396
  };
415
397
  validateTestClaim(manifest, p.claimPath);
416
- const handle = await open(
417
- p.claimPath,
418
- constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
419
- 0o600,
420
- );
421
- try {
422
- await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
423
- await handle.sync();
424
- } finally {
425
- await handle.close();
426
- }
427
- await syncDirectory(p.claimDirectory);
398
+ await createTestClaimFile(p.claimPath, `${JSON.stringify(manifest, null, 2)}\n`);
428
399
  return { claimPath: p.claimPath, manifest };
429
400
  }
430
401
 
@@ -436,27 +407,7 @@ export async function writeTestClaim(claimPath: string, manifest: TestClaimManif
436
407
  if (!isDeepStrictEqual(previous[key], manifest[key]))
437
408
  throw new Error('Test claim identity is immutable');
438
409
  }
439
- const temp = `${claimPath}.${randomBytes(12).toString('hex')}.tmp`;
440
- const handle = await open(
441
- temp,
442
- constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
443
- 0o600,
444
- );
445
- try {
446
- try {
447
- await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
448
- await handle.sync();
449
- } finally {
450
- await handle.close();
451
- }
452
- assertPrivateFile(claimPath);
453
- await rename(temp, claimPath);
454
- await syncDirectory(path.dirname(claimPath));
455
- } finally {
456
- await unlink(temp).catch(error => {
457
- if (error.code !== 'ENOENT') throw error;
458
- });
459
- }
410
+ await replaceTestClaimFile(claimPath, `${JSON.stringify(manifest, null, 2)}\n`);
460
411
  }
461
412
 
462
413
  function applyEvent(m: TestClaimManifest, event: TestClaimEvent) {
@@ -484,7 +435,13 @@ function applyEvent(m: TestClaimManifest, event: TestClaimEvent) {
484
435
  throw new Error('Process needs a write-ahead intent');
485
436
  m.processes.push(event.process);
486
437
  } else {
487
- if (old.role !== event.process.role) throw new Error('Test claim process identity changed');
438
+ if (
439
+ old.role !== event.process.role ||
440
+ old.containment !== event.process.containment ||
441
+ old.jobId !== event.process.jobId
442
+ ) {
443
+ throw new Error('Test claim process identity changed');
444
+ }
488
445
  if (
489
446
  event.process.profilePath !== undefined &&
490
447
  old.profilePath !== event.process.profilePath
@@ -577,11 +534,131 @@ function applyEvent(m: TestClaimManifest, event: TestClaimEvent) {
577
534
  validateTestClaim(m);
578
535
  }
579
536
 
537
+ interface WindowsJobContainmentQuery {
538
+ runId: string;
539
+ guardianId: string;
540
+ process: TestClaimOwner;
541
+ }
542
+ type WindowsJobVerifier = (identity: TestClaimOwner) => Promise<boolean>;
543
+ // Native verifiers are controller-local capabilities, never HTTP registrations or journal data.
544
+ const liveWindowsBrokers = new Map<
545
+ string,
546
+ {
547
+ url: string;
548
+ register: (guardian: TestClaimProcess, verify: WindowsJobVerifier) => Promise<() => void>;
549
+ }
550
+ >();
551
+
552
+ function validateWindowsJobQuery(value: unknown): asserts value is WindowsJobContainmentQuery {
553
+ keys(value, ['runId', 'guardianId', 'process']);
554
+ const query = value as WindowsJobContainmentQuery;
555
+ resolveTestRunId('test', query.runId);
556
+ label(query.guardianId);
557
+ keys(query.process, ['pid', 'startTime', 'host']);
558
+ integer(query.process.pid, 1, 0xffffffff);
559
+ text(query.process.startTime);
560
+ if (!/^[1-9]\d{0,19}$/.test(query.process.startTime))
561
+ throw new Error('Invalid Windows containment creation time');
562
+ text(query.process.host);
563
+ }
564
+
565
+ /** Only the controller hosting this authenticated broker can attach its sole native Job owner. */
566
+ export async function registerTestClaimWindowsJobVerifier(
567
+ env: TestClaimEnv,
568
+ guardian: TestClaimProcess,
569
+ verify: WindowsJobVerifier,
570
+ ): Promise<() => void> {
571
+ if (!hasTestClaimTransport(env)) throw new Error('Windows containment requires a claim broker');
572
+ if (!resolveTestRunId(env.META_MODE!, env.VONA_TEST_RUN_ID))
573
+ throw new Error('Windows containment requires test identity');
574
+ const broker = liveWindowsBrokers.get(env[TEST_CLAIM_TOKEN_ENV]!);
575
+ if (!broker || broker.url !== env[TEST_CLAIM_URL_ENV])
576
+ throw new Error('Windows containment verifier must belong to the local claim broker');
577
+ return await broker.register(guardian, verify);
578
+ }
579
+
580
580
  export async function startTestClaimBroker(claimPath: string) {
581
581
  await loadTestClaim(claimPath);
582
582
  const token = randomBytes(32).toString('hex');
583
583
  let queue: Promise<void> = Promise.resolve();
584
584
  let closing = false;
585
+ const verifiers = new Map<string, { guardian: TestClaimProcess; verify: WindowsJobVerifier }>();
586
+ let queries = 0;
587
+ const containment = async (query: unknown) => {
588
+ validateWindowsJobQuery(query);
589
+ if (closing || queries >= 32) throw new Error('Windows containment verifier unavailable');
590
+ queries++;
591
+ try {
592
+ await queue;
593
+ const manifest = await loadTestClaim(claimPath);
594
+ const live = verifiers.get(query.guardianId);
595
+ const guardian = manifest.processes.find(item => item.id === query.guardianId);
596
+ if (
597
+ manifest.kind !== 'e2e' ||
598
+ manifest.runId !== query.runId ||
599
+ guardian?.stage !== 'registered' ||
600
+ guardian.role !== 'guardian' ||
601
+ guardian.containment !== 'windows-job' ||
602
+ guardian.host !== query.process.host ||
603
+ !live ||
604
+ !isDeepStrictEqual(live.guardian, guardian)
605
+ ) {
606
+ throw new Error('Windows containment requires a registered live E2E guardian');
607
+ }
608
+ let timer: NodeJS.Timeout | undefined;
609
+ try {
610
+ const verified = await Promise.race([
611
+ live.verify(query.process),
612
+ new Promise<never>((_, reject) => {
613
+ timer = setTimeout(
614
+ () => reject(new Error('Windows containment query timed out')),
615
+ 5000,
616
+ );
617
+ }),
618
+ ]);
619
+ await queue;
620
+ const current = await loadTestClaim(claimPath);
621
+ if (
622
+ !verified ||
623
+ closing ||
624
+ verifiers.get(query.guardianId) !== live ||
625
+ !isDeepStrictEqual(
626
+ current.processes.find(item => item.id === query.guardianId),
627
+ guardian,
628
+ )
629
+ ) {
630
+ throw new Error('Windows process is not in the designated live Job');
631
+ }
632
+ } finally {
633
+ clearTimeout(timer);
634
+ }
635
+ } finally {
636
+ queries--;
637
+ }
638
+ };
639
+ const register = async (guardian: TestClaimProcess, verify: WindowsJobVerifier) => {
640
+ await queue;
641
+ const manifest = await loadTestClaim(claimPath);
642
+ if (
643
+ closing ||
644
+ guardian.role !== 'guardian' ||
645
+ guardian.stage !== 'registered' ||
646
+ guardian.containment !== 'windows-job' ||
647
+ typeof verify !== 'function' ||
648
+ verifiers.has(guardian.id) ||
649
+ !isDeepStrictEqual(
650
+ manifest.processes.find(item => item.id === guardian.id),
651
+ guardian,
652
+ )
653
+ ) {
654
+ throw new Error('Windows containment verifier needs a durable registered guardian');
655
+ }
656
+ const live = { guardian: structuredClone(guardian), verify };
657
+ verifiers.set(guardian.id, live);
658
+ return () => {
659
+ if (verifiers.get(guardian.id) === live) verifiers.delete(guardian.id);
660
+ };
661
+ };
585
662
  const update = (event: TestClaimEvent) => {
586
663
  if (closing) return Promise.reject(new Error('Test claim broker is closed'));
587
664
  const result = queue.then(async () => {
@@ -597,7 +674,7 @@ export async function startTestClaimBroker(claimPath: string) {
597
674
  const expected = `Bearer ${token}`;
598
675
  if (
599
676
  req.method !== 'POST' ||
600
- req.url !== '/event' ||
677
+ !['/event', '/containment'].includes(req.url!) ||
601
678
  typeof auth !== 'string' ||
602
679
  Buffer.byteLength(auth) !== Buffer.byteLength(expected) ||
603
680
  !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))
@@ -612,7 +689,8 @@ export async function startTestClaimBroker(claimPath: string) {
612
689
  body += chunk.toString('utf8');
613
690
  if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Test claim event too large');
614
691
  }
615
- await update(parseTestClaimJson(body));
692
+ if (req.url === '/containment') await containment(parseTestClaimJson(body));
693
+ else await update(parseTestClaimJson(body));
616
694
  res.writeHead(204).end();
617
695
  } catch {
618
696
  res.writeHead(409).end('Test claim registration rejected');
@@ -626,14 +704,18 @@ export async function startTestClaimBroker(claimPath: string) {
626
704
  });
627
705
  const address = server.address();
628
706
  if (!address || typeof address === 'string') throw new Error('Cannot bind test claim broker');
707
+ const url = `http://127.0.0.1:${address.port}/event`;
708
+ liveWindowsBrokers.set(token, { url, register });
629
709
  return {
630
710
  env: {
631
- [TEST_CLAIM_URL_ENV]: `http://127.0.0.1:${address.port}/event`,
711
+ [TEST_CLAIM_URL_ENV]: url,
632
712
  [TEST_CLAIM_TOKEN_ENV]: token,
633
713
  },
634
714
  update,
635
715
  async close() {
636
716
  closing = true;
717
+ liveWindowsBrokers.delete(token);
718
+ verifiers.clear();
637
719
  await new Promise<void>((resolve, reject) =>
638
720
  server.close(error => (error ? reject(error) : resolve())),
639
721
  );
@@ -688,6 +770,32 @@ export async function sendTestClaimEvent(env: TestClaimEnv, event: TestClaimEven
688
770
  }
689
771
  }
690
772
 
773
+ /** A transient authenticated gate: success is live exact membership, never a durable process event. */
774
+ export async function assertTestClaimWindowsJob(env: TestClaimEnv, identity: TestClaimOwner) {
775
+ try {
776
+ if (!hasTestClaimTransport(env)) throw new Error('Missing test claim transport');
777
+ const query: WindowsJobContainmentQuery = {
778
+ runId: env.VONA_TEST_RUN_ID!,
779
+ guardianId: env[TEST_CLAIM_WINDOWS_GUARDIAN_ENV]!,
780
+ process: { pid: identity.pid, startTime: identity.startTime, host: identity.host },
781
+ };
782
+ validateWindowsJobQuery(query);
783
+ const response = await fetch(new URL('/containment', env[TEST_CLAIM_URL_ENV]!), {
784
+ method: 'POST',
785
+ headers: {
786
+ 'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
787
+ 'content-type': 'application/json',
788
+ },
789
+ body: JSON.stringify(query),
790
+ signal: AbortSignal.timeout(7000),
791
+ redirect: 'error',
792
+ });
793
+ if (response.status !== 204) throw new Error('Containment rejected');
794
+ } catch {
795
+ throw new Error('Managed Windows browser containment verification failed');
796
+ }
797
+ }
798
+
691
799
  export function getTestClaimRedisPrefix(
692
800
  runId: string,
693
801
  clientName: string,
@@ -0,0 +1,247 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import {
4
+ chmod,
5
+ link,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ realpath,
12
+ rm,
13
+ symlink,
14
+ writeFile,
15
+ } from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { test } from 'node:test';
19
+
20
+ import {
21
+ acquireTestClaimCleanupLock,
22
+ assertTestClaimFilesystemSafePath,
23
+ assertTestClaimPrivateDirectory,
24
+ createTestClaimFile,
25
+ deleteTestClaimFile,
26
+ ensureTestClaimPrivateDirectory,
27
+ readTestClaimFile,
28
+ replaceTestClaimFile,
29
+ TEST_CLAIM_FILE_LIMIT,
30
+ } from './testClaimFilesystem.ts';
31
+ import {
32
+ getWindowsTestClaimFilesystemHelper,
33
+ windowsTestClaimFilesystem,
34
+ } from './testClaimFilesystemWindows.ts';
35
+
36
+ async function fixture() {
37
+ const root = await mkdtemp(path.join(await realpath(os.tmpdir()), 'claim-filesystem-'));
38
+ const directory = path.join(root, 'private');
39
+ await ensureTestClaimPrivateDirectory(directory);
40
+ return {
41
+ root,
42
+ directory,
43
+ file: path.join(directory, 'claim.json'),
44
+ close: () => rm(root, { recursive: true, force: true }),
45
+ };
46
+ }
47
+
48
+ test('helper assets resolve from source and bundled package locations', () => {
49
+ const helper = getWindowsTestClaimFilesystemHelper();
50
+ assert.ok(helper.endsWith(path.join('src', 'lib', 'core', 'testClaimFilesystemWindows.ps1')));
51
+ assert.equal(
52
+ getWindowsTestClaimFilesystemHelper(path.resolve(path.dirname(helper), '../../../dist')),
53
+ helper,
54
+ );
55
+ });
56
+
57
+ test('private claim storage creates exclusively, bounds reads, atomically replaces and deletes', async () => {
58
+ const f = await fixture();
59
+ try {
60
+ assert.equal(await assertTestClaimPrivateDirectory(f.directory, false), true);
61
+ await createTestClaimFile(f.file, '{"state":"initial","unicode":"雪"}\n');
62
+ await assert.rejects(createTestClaimFile(f.file, 'overwrite'), { code: 'EEXIST' });
63
+ assert.equal(await readTestClaimFile(f.file), '{"state":"initial","unicode":"雪"}\n');
64
+ await replaceTestClaimFile(f.file, '{"state":"committed"}\n');
65
+ assert.equal(await readTestClaimFile(f.file), '{"state":"committed"}\n');
66
+ await assert.rejects(
67
+ replaceTestClaimFile(f.file, 'x'.repeat(TEST_CLAIM_FILE_LIMIT + 1)),
68
+ /size/,
69
+ );
70
+ assert.equal(await readTestClaimFile(f.file), '{"state":"committed"}\n');
71
+ await writeFile(f.file, 'x'.repeat(TEST_CLAIM_FILE_LIMIT + 1));
72
+ await assert.rejects(readTestClaimFile(f.file));
73
+ await deleteTestClaimFile(f.file);
74
+ await assert.rejects(lstat(f.file), { code: 'ENOENT' });
75
+ } finally {
76
+ await f.close();
77
+ }
78
+ });
79
+
80
+ test('cleanup lock contention is exclusive and release never unlinks a replacement token', async () => {
81
+ const f = await fixture();
82
+ const lockPath = `${f.file}.cleanup.lock`;
83
+ try {
84
+ const lock = await acquireTestClaimCleanupLock(lockPath);
85
+ await assert.rejects(acquireTestClaimCleanupLock(lockPath), { code: 'EEXIST' });
86
+ const contents = await readTestClaimFile(lockPath);
87
+ assert.match(JSON.parse(contents).token, /^[a-f\d]{48}$/);
88
+ await lock.release();
89
+ await lock.release();
90
+ const old = await acquireTestClaimCleanupLock(lockPath);
91
+ await deleteTestClaimFile(lockPath);
92
+ const replacement = await acquireTestClaimCleanupLock(lockPath);
93
+ await assert.rejects(old.release());
94
+ await lstat(lockPath);
95
+ await replacement.release();
96
+ await assert.rejects(lstat(lockPath), { code: 'ENOENT' });
97
+ } finally {
98
+ await f.close();
99
+ }
100
+ });
101
+
102
+ test('paths reject redirected ancestors and leaf hardlinks without touching the sentinel', async () => {
103
+ const f = await fixture();
104
+ try {
105
+ const outside = path.join(f.root, 'outside');
106
+ await mkdir(outside);
107
+ const redirect = path.join(f.root, 'redirect');
108
+ await symlink(outside, redirect, process.platform === 'win32' ? 'junction' : 'dir');
109
+ assert.throws(
110
+ () => assertTestClaimFilesystemSafePath(path.join(redirect, 'missing.json')),
111
+ /Unsafe/,
112
+ );
113
+ await createTestClaimFile(f.file, 'sentinel');
114
+ const alias = path.join(f.directory, 'alias.json');
115
+ await link(f.file, alias);
116
+ await assert.rejects(readTestClaimFile(alias));
117
+ await assert.rejects(replaceTestClaimFile(alias, 'destroyed'));
118
+ await assert.rejects(deleteTestClaimFile(alias));
119
+ assert.equal(await readFile(f.file, 'utf8'), 'sentinel');
120
+ assert.deepEqual((await readdir(f.directory)).sort(), ['alias.json', 'claim.json']);
121
+ } finally {
122
+ await f.close();
123
+ }
124
+ });
125
+
126
+ test(
127
+ 'POSIX unsafe existing modes are rejected rather than repaired',
128
+ { skip: process.platform === 'win32' },
129
+ async () => {
130
+ const f = await fixture();
131
+ try {
132
+ await chmod(f.directory, 0o755);
133
+ await assert.rejects(ensureTestClaimPrivateDirectory(f.directory), /Unsafe/);
134
+ assert.equal((await lstat(f.directory)).mode & 0o777, 0o755);
135
+ } finally {
136
+ await f.close();
137
+ }
138
+ },
139
+ );
140
+
141
+ test(
142
+ 'Windows protected DACLs reject broad file and directory grants without repairing them',
143
+ { skip: process.platform !== 'win32' },
144
+ async () => {
145
+ const f = await fixture();
146
+ try {
147
+ await createTestClaimFile(f.file, 'private');
148
+ execFileSync('icacls.exe', [f.file, '/grant', '*S-1-1-0:(R)'], {
149
+ windowsHide: true,
150
+ stdio: 'pipe',
151
+ });
152
+ await assert.rejects(readTestClaimFile(f.file), /Unsafe/);
153
+ await assert.rejects(replaceTestClaimFile(f.file, 'wrong'), /Unsafe/);
154
+ assert.equal(await readFile(f.file, 'utf8'), 'private');
155
+ execFileSync('icacls.exe', [f.directory, '/grant', '*S-1-1-0:(R)'], {
156
+ windowsHide: true,
157
+ stdio: 'pipe',
158
+ });
159
+ await assert.rejects(ensureTestClaimPrivateDirectory(f.directory), /Unsafe/);
160
+ await assert.rejects(assertTestClaimPrivateDirectory(f.directory, false), /Unsafe/);
161
+ } finally {
162
+ await f.close();
163
+ }
164
+ },
165
+ );
166
+
167
+ test(
168
+ 'Windows replacement failure retains old manifest and removes flushed temporary file',
169
+ { skip: process.platform !== 'win32' },
170
+ async () => {
171
+ const f = await fixture();
172
+ try {
173
+ await createTestClaimFile(f.file, 'previous committed inventory');
174
+ // A native handle denying FILE_SHARE_DELETE prevents replacement. Node opens
175
+ // with delete sharing, so hold this deliberately through a separate PS7 peer.
176
+ const { spawn } = await import('node:child_process');
177
+ const script =
178
+ '$h=[System.IO.File]::Open($env:VONA_CLAIM_TEST_FILE,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::Read);[Console]::Out.WriteLine("ready");[Console]::Out.Flush();[Console]::In.ReadLine()|Out-Null;$h.Dispose()';
179
+ const child = spawn(
180
+ 'pwsh',
181
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
182
+ {
183
+ windowsHide: true,
184
+ stdio: ['pipe', 'pipe', 'pipe'],
185
+ env: { ...process.env, VONA_CLAIM_TEST_FILE: f.file },
186
+ },
187
+ );
188
+ try {
189
+ await new Promise<void>((resolve, reject) => {
190
+ const timer = setTimeout(
191
+ () => reject(new Error('fixture handle readiness timeout')),
192
+ 15000,
193
+ );
194
+ child.once('error', error => {
195
+ clearTimeout(timer);
196
+ reject(error);
197
+ });
198
+ child.once('exit', () => {
199
+ clearTimeout(timer);
200
+ reject(new Error('fixture handle exited'));
201
+ });
202
+ child.stdout.once('data', chunk => {
203
+ clearTimeout(timer);
204
+ assert.match(String(chunk), /ready/);
205
+ resolve();
206
+ });
207
+ });
208
+ await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'));
209
+ assert.equal(await readFile(f.file, 'utf8'), 'previous committed inventory');
210
+ assert.deepEqual(await readdir(f.directory), ['claim.json']);
211
+ } finally {
212
+ child.stdin.end('\n');
213
+ await new Promise<void>(resolve => {
214
+ if (child.exitCode !== null) resolve();
215
+ else child.once('exit', () => resolve());
216
+ });
217
+ }
218
+ await replaceTestClaimFile(f.file, 'new committed inventory');
219
+ assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
220
+ } finally {
221
+ await f.close();
222
+ }
223
+ },
224
+ );
225
+
226
+ test(
227
+ 'Windows helper rejects UNC, device, alternate stream paths and missing parents',
228
+ { skip: process.platform !== 'win32' },
229
+ async () => {
230
+ const f = await fixture();
231
+ try {
232
+ for (const file of [
233
+ '\\\\localhost\\C$\\claim.json',
234
+ '\\\\?\\C:\\claim.json',
235
+ `${f.file}:stream`,
236
+ path.join(f.root, 'missing', 'private'),
237
+ ]) {
238
+ await assert.rejects(
239
+ windowsTestClaimFilesystem({ command: 'ensure-directory', path: file }),
240
+ /Unsafe/,
241
+ );
242
+ }
243
+ } finally {
244
+ await f.close();
245
+ }
246
+ },
247
+ );