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.
- package/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +137 -0
- package/.github/workflows/playwright-e2e.yml +163 -13
- package/.github/workflows/vona-test-sqlite3.yml +77 -7
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +4 -2
- package/package.json +1 -1
- package/repo-docs/frontend/scripts.md +2 -0
- package/repo-docs/reference/repo-scripts.md +27 -1
- package/repo-e2e/scripts/e2e.test.ts +1 -1
- package/repo-e2e/scripts/managedTest.test.ts +115 -3
- package/repo-e2e/scripts/managedTest.ts +69 -20
- package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
- package/repo-e2e/scripts/runE2e.test.ts +109 -0
- package/repo-e2e/scripts/runE2e.ts +58 -39
- package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
- package/repo-e2e/scripts/runE2eArgs.ts +5 -0
- package/repo-e2e/specs/a-commerce.spec.ts +10 -2
- package/repo-e2e/specs/home-user-account.spec.ts +4 -0
- package/repo-e2e/specs/markdown.spec.ts +14 -3
- package/scripts/generateOpenapiIsolated.test.ts +5 -1
- package/scripts/runIsolatedTest.test.ts +403 -36
- package/scripts/runIsolatedTest.ts +139 -12
- package/scripts/testBrowserGuardianWindows.test.ts +327 -0
- package/scripts/testClaimCleanup.test.ts +25 -9
- package/scripts/testClaimCleanup.ts +14 -45
- package/scripts/testClaimProcesses.test.ts +81 -2
- package/scripts/testClaimProcesses.ts +46 -10
- package/scripts/testProcessGuardianWindows.test.ts +602 -0
- package/scripts/testProcessGuardianWindows.ts +155 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
- package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
- package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
- package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +42 -181
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +4 -4
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +2 -2
- package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
- package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +324 -63
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
- package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- 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
|
-
|
|
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, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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]:
|
|
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
|
+
);
|