@celilo/e2e 0.9.2-alpha.0 → 0.9.3
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/config/proxy/squid.conf +7 -2
- package/config/resolver/unbound.conf +13 -3
- package/docker/Dockerfile.minio +1 -1
- package/docker/Dockerfile.target-machine +1 -1
- package/docker/Dockerfile.target-machine-docker +1 -1
- package/docker/Dockerfile.target-technitium +1 -1
- package/package.json +4 -5
- package/scripts/pack-celilo-packages.ts +79 -51
- package/src/cli/build.ts +28 -9
- package/src/cli/command-registry.ts +34 -6
- package/src/cli/index.ts +53 -21
- package/src/container-manager.ts +1 -1
- package/src/runner.ts +128 -57
- package/registry-server/package.json +0 -17
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/auth.ts +0 -105
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/bootstrap.ts +0 -246
- package/registry-server/src/index.ts +0 -16
- package/registry-server/src/landing.ts +0 -90
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/rate-limit.ts +0 -83
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/scoped-token-store.ts +0 -110
- package/registry-server/src/server.test.ts +0 -509
- package/registry-server/src/server.ts +0 -572
- package/registry-server/src/storage.test.ts +0 -148
- package/registry-server/src/storage.ts +0 -150
- package/registry-server/src/validation.test.ts +0 -86
- package/registry-server/src/validation.ts +0 -60
- package/registry-server/tsconfig.json +0 -15
package/src/runner.ts
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
* Countdown goes negative when over estimate.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { spawn, spawnSync
|
|
20
|
+
import { execSync, spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
23
|
-
import { parse as parseYaml } from 'yaml';
|
|
24
23
|
import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
|
|
24
|
+
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import {
|
|
26
26
|
emitRunCompleted,
|
|
27
27
|
emitRunFailed,
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
emitTestStarted,
|
|
31
31
|
} from './bus-events';
|
|
32
32
|
import { parseLine } from './parse-line';
|
|
33
|
-
import {
|
|
33
|
+
import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
|
|
34
34
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
35
35
|
|
|
36
36
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
@@ -50,7 +50,9 @@ function resolveModuleTestsDir(dir: string): string {
|
|
|
50
50
|
const manifestPath = join(dir, 'manifest.yml');
|
|
51
51
|
if (existsSync(manifestPath)) {
|
|
52
52
|
try {
|
|
53
|
-
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
|
|
53
|
+
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
|
|
54
|
+
e2e?: { tests_dir?: string };
|
|
55
|
+
};
|
|
54
56
|
if (parsed?.e2e?.tests_dir) return resolve(dir, parsed.e2e.tests_dir);
|
|
55
57
|
} catch {}
|
|
56
58
|
}
|
|
@@ -84,11 +86,14 @@ const flagCi = rawArgs.includes('--ci');
|
|
|
84
86
|
const flagNoCi = rawArgs.includes('--no-ci');
|
|
85
87
|
const ci = flagCi || (!flagNoCi && !!process.env.CI);
|
|
86
88
|
const patterns = rawArgs.filter((a) => !a.startsWith('--'));
|
|
87
|
-
//
|
|
88
|
-
// known-failing
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
89
|
+
// Quarantine: tests whose file contains the marker `cele2e-ci-unsafe` (a comment
|
|
90
|
+
// naming the reason + ISS) are known-failing / builder-flaky and tracked
|
|
91
|
+
// separately, so they must never turn a broad run red. They are SKIPPED by
|
|
92
|
+
// default in every broad run (`--all`, `--all-modules`, `--ci-safe`, bare). Two
|
|
93
|
+
// ways to run one anyway: name it explicitly (`cele2e run <name>`), or pass
|
|
94
|
+
// `--complete` (the deliberate "run everything, including quarantined" opt-in).
|
|
95
|
+
// `--complete` is passed through from the CLI (not stripped) so the runner sees it.
|
|
96
|
+
const flagComplete = rawArgs.includes('--complete');
|
|
92
97
|
// --notify: fire a desktop notification when the run finishes (handy for long
|
|
93
98
|
// detached/background runs). Best-effort — never affects the exit code.
|
|
94
99
|
const flagNotify = rawArgs.includes('--notify');
|
|
@@ -123,9 +128,20 @@ function notify(title: string, message: string): void {
|
|
|
123
128
|
if (!flagNotify) return;
|
|
124
129
|
try {
|
|
125
130
|
if (process.platform === 'darwin') {
|
|
126
|
-
const r = spawnSync(
|
|
131
|
+
const r = spawnSync(
|
|
132
|
+
'terminal-notifier',
|
|
133
|
+
['-title', title, '-message', message, '-sound', 'Glass'],
|
|
134
|
+
{ stdio: 'ignore' },
|
|
135
|
+
);
|
|
127
136
|
if (r.error) {
|
|
128
|
-
spawnSync(
|
|
137
|
+
spawnSync(
|
|
138
|
+
'osascript',
|
|
139
|
+
[
|
|
140
|
+
'-e',
|
|
141
|
+
`display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`,
|
|
142
|
+
],
|
|
143
|
+
{ stdio: 'ignore' },
|
|
144
|
+
);
|
|
129
145
|
}
|
|
130
146
|
spawnSync('say', [message], { stdio: 'ignore' });
|
|
131
147
|
} else if (process.platform === 'linux') {
|
|
@@ -182,7 +198,10 @@ function extractFailureMessage(lines: string[], exitCode: number): string {
|
|
|
182
198
|
// bun's own test failure: "✗ test name [Xms]" — extract the failing test name
|
|
183
199
|
const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
|
|
184
200
|
if (bunFailIdx >= 0) {
|
|
185
|
-
const testName = plain[bunFailIdx]
|
|
201
|
+
const testName = plain[bunFailIdx]
|
|
202
|
+
.replace(/^\s*✗\s+/, '')
|
|
203
|
+
.replace(/\s*\[\d+ms\].*$/, '')
|
|
204
|
+
.trim();
|
|
186
205
|
// Grab the error detail on the next few lines (bun indents it under the test)
|
|
187
206
|
const detail = plain
|
|
188
207
|
.slice(bunFailIdx + 1, bunFailIdx + 6)
|
|
@@ -215,7 +234,13 @@ function extractFailureMessage(lines: string[], exitCode: number): string {
|
|
|
215
234
|
|
|
216
235
|
// Last resort: print the last few non-empty, non-progress lines from the output
|
|
217
236
|
const tail = plain
|
|
218
|
-
.filter(
|
|
237
|
+
.filter(
|
|
238
|
+
(l) =>
|
|
239
|
+
l.length > 0 &&
|
|
240
|
+
!l.startsWith('[progress') &&
|
|
241
|
+
!l.startsWith('[e2e:') &&
|
|
242
|
+
!l.startsWith('[ansible:'),
|
|
243
|
+
)
|
|
219
244
|
.slice(-3)
|
|
220
245
|
.map((l) => l.trim())
|
|
221
246
|
.join(' | ');
|
|
@@ -270,7 +295,9 @@ async function runTest(
|
|
|
270
295
|
testEnv.CELILO_E2E_REUSE = '1';
|
|
271
296
|
testEnv.CELILO_E2E_PROJECT = data.projectName;
|
|
272
297
|
} catch {
|
|
273
|
-
console.error(
|
|
298
|
+
console.error(
|
|
299
|
+
`${red}✗ --reuse: no persistent network found. Run with --keep first.${reset}`,
|
|
300
|
+
);
|
|
274
301
|
process.exit(1);
|
|
275
302
|
}
|
|
276
303
|
}
|
|
@@ -298,23 +325,32 @@ async function runTest(
|
|
|
298
325
|
process.stdout.write(`${bold}\x1b[33m║${reset} Container: ${bold}${container}${reset}\n`);
|
|
299
326
|
process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
|
|
300
327
|
process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Run commands:${reset}\n`);
|
|
301
|
-
process.stdout.write(
|
|
302
|
-
|
|
328
|
+
process.stdout.write(
|
|
329
|
+
`${bold}\x1b[33m║${reset} docker compose -f docker-compose.test.yml \\\n`,
|
|
330
|
+
);
|
|
331
|
+
process.stdout.write(
|
|
332
|
+
`${bold}\x1b[33m║${reset} -p ${project} exec ${container} bash -c '<cmd>'\n`,
|
|
333
|
+
);
|
|
303
334
|
process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
|
|
304
335
|
|
|
305
336
|
if (interactive) {
|
|
306
|
-
process.stdout.write(
|
|
337
|
+
process.stdout.write(
|
|
338
|
+
`${bold}\x1b[33m║${reset} ${dim}Or get a shell: type 'exit' to resume the test.${reset}\n`,
|
|
339
|
+
);
|
|
307
340
|
process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
|
|
308
341
|
|
|
309
|
-
spawnSync(
|
|
310
|
-
'
|
|
311
|
-
'exec', container, 'bash',
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
342
|
+
spawnSync(
|
|
343
|
+
'docker',
|
|
344
|
+
['compose', '-f', 'docker-compose.test.yml', '-p', project, 'exec', container, 'bash'],
|
|
345
|
+
{
|
|
346
|
+
cwd: PKG_DIR,
|
|
347
|
+
stdio: 'inherit',
|
|
348
|
+
},
|
|
349
|
+
);
|
|
316
350
|
} else {
|
|
317
|
-
process.stdout.write(
|
|
351
|
+
process.stdout.write(
|
|
352
|
+
`${bold}\x1b[33m║${reset} ${dim}Resume: touch ${signalFile}${reset}\n`,
|
|
353
|
+
);
|
|
318
354
|
process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
|
|
319
355
|
|
|
320
356
|
// Non-interactive: just wait for the signal file (no shell)
|
|
@@ -376,7 +412,7 @@ async function runTest(
|
|
|
376
412
|
writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
|
|
377
413
|
|
|
378
414
|
const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
|
|
379
|
-
const expectCount = expectMatch ? parseInt(expectMatch[1], 10) : undefined;
|
|
415
|
+
const expectCount = expectMatch ? Number.parseInt(expectMatch[1], 10) : undefined;
|
|
380
416
|
|
|
381
417
|
if (code === 0) {
|
|
382
418
|
if (expectCount === 0) {
|
|
@@ -389,7 +425,13 @@ async function runTest(
|
|
|
389
425
|
error: '0 assertions — test likely exited before reaching assertions',
|
|
390
426
|
});
|
|
391
427
|
} else {
|
|
392
|
-
resolveTest({
|
|
428
|
+
resolveTest({
|
|
429
|
+
name,
|
|
430
|
+
status: 'pass',
|
|
431
|
+
duration,
|
|
432
|
+
hadDebugPause,
|
|
433
|
+
projectName: capturedProjectName,
|
|
434
|
+
});
|
|
393
435
|
}
|
|
394
436
|
} else {
|
|
395
437
|
// Collect a raw tail: strip ANSI, drop internal protocol lines, keep last 30
|
|
@@ -397,12 +439,13 @@ async function runTest(
|
|
|
397
439
|
.split('\n')
|
|
398
440
|
.map(stripAnsi)
|
|
399
441
|
.map((l) => l.trimEnd())
|
|
400
|
-
.filter(
|
|
401
|
-
l
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
442
|
+
.filter(
|
|
443
|
+
(l) =>
|
|
444
|
+
l.length > 0 &&
|
|
445
|
+
!l.startsWith('[progress') &&
|
|
446
|
+
!l.startsWith('[e2e:') &&
|
|
447
|
+
!l.startsWith('[ansible:') &&
|
|
448
|
+
!l.startsWith('[debug:'),
|
|
406
449
|
)
|
|
407
450
|
.slice(-30)
|
|
408
451
|
.join('\n');
|
|
@@ -424,10 +467,12 @@ async function runTest(
|
|
|
424
467
|
|
|
425
468
|
function dockerComposeShared(cmd: string): string {
|
|
426
469
|
try {
|
|
427
|
-
return execSync(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
470
|
+
return execSync(`docker compose -f docker-compose.shared.yml -p celilo-e2e-shared ${cmd}`, {
|
|
471
|
+
cwd: PKG_DIR,
|
|
472
|
+
encoding: 'utf-8',
|
|
473
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
474
|
+
timeout: 60_000,
|
|
475
|
+
}).trim();
|
|
431
476
|
} catch {
|
|
432
477
|
return '';
|
|
433
478
|
}
|
|
@@ -435,7 +480,9 @@ function dockerComposeShared(cmd: string): string {
|
|
|
435
480
|
|
|
436
481
|
function stopSharedInfra(): void {
|
|
437
482
|
dockerComposeShared('down --volumes --remove-orphans');
|
|
438
|
-
try {
|
|
483
|
+
try {
|
|
484
|
+
execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 });
|
|
485
|
+
} catch {}
|
|
439
486
|
}
|
|
440
487
|
|
|
441
488
|
/**
|
|
@@ -486,14 +533,20 @@ async function main() {
|
|
|
486
533
|
// docker mutation so a competing run can't wipe our shared infra mid-setup.
|
|
487
534
|
const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
|
|
488
535
|
try {
|
|
489
|
-
acquireRunLock({
|
|
536
|
+
acquireRunLock({
|
|
537
|
+
test: lockLabel,
|
|
538
|
+
runId: process.env.CELE2E_RUN_ID ?? ambientRunId,
|
|
539
|
+
allowKept: flagReuse,
|
|
540
|
+
});
|
|
490
541
|
// A --reuse run keeps using a kept stack that stays up afterwards — hold the
|
|
491
542
|
// lock in kept state so it isn't freed out from under the reused network.
|
|
492
543
|
if (flagReuse) markKept();
|
|
493
544
|
} catch (err) {
|
|
494
545
|
if (err instanceof E2eBusyError) {
|
|
495
546
|
console.error(`\n${red}${err.message}${reset}`);
|
|
496
|
-
console.error(
|
|
547
|
+
console.error(
|
|
548
|
+
`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`,
|
|
549
|
+
);
|
|
497
550
|
process.exit(3);
|
|
498
551
|
}
|
|
499
552
|
throw err;
|
|
@@ -511,7 +564,9 @@ async function main() {
|
|
|
511
564
|
const tp = resolveModuleTestsDir(dir);
|
|
512
565
|
if (!existsSync(tp)) {
|
|
513
566
|
console.error(`${red}No tests directory found at ${tp}${reset}`);
|
|
514
|
-
console.error(
|
|
567
|
+
console.error(
|
|
568
|
+
`${red}Set e2e.tests_dir in ${join(dir, 'manifest.yml')} to point to your test files.${reset}`,
|
|
569
|
+
);
|
|
515
570
|
process.exit(1);
|
|
516
571
|
}
|
|
517
572
|
// Auto-install e2e/ dependencies if package.json exists but node_modules is missing
|
|
@@ -573,11 +628,10 @@ async function main() {
|
|
|
573
628
|
testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
|
|
574
629
|
}
|
|
575
630
|
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
if (flagCiSafe) {
|
|
631
|
+
// Drop quarantined tests (file contains `cele2e-ci-unsafe`) from broad runs so
|
|
632
|
+
// they never turn a full run red. Skipped by default; bypassed only when the
|
|
633
|
+
// user explicitly named tests (`patterns.length > 0`) or passed `--complete`.
|
|
634
|
+
if (!flagComplete && patterns.length === 0) {
|
|
581
635
|
const before = testFiles.length;
|
|
582
636
|
testFiles = testFiles.filter((f) => {
|
|
583
637
|
try {
|
|
@@ -588,7 +642,9 @@ async function main() {
|
|
|
588
642
|
});
|
|
589
643
|
const skipped = before - testFiles.length;
|
|
590
644
|
if (skipped > 0) {
|
|
591
|
-
console.log(
|
|
645
|
+
console.log(
|
|
646
|
+
`${dim}skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s) — pass --complete or name one to run it${reset}`,
|
|
647
|
+
);
|
|
592
648
|
}
|
|
593
649
|
}
|
|
594
650
|
|
|
@@ -637,7 +693,9 @@ async function main() {
|
|
|
637
693
|
console.log(`${bold}╔══════════════════════════════════════════════════════════════${reset}`);
|
|
638
694
|
console.log(`${bold}║ Celilo E2E Tests${moduleLabel}${reset}`);
|
|
639
695
|
if (hasHistory) {
|
|
640
|
-
console.log(
|
|
696
|
+
console.log(
|
|
697
|
+
`${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected)}${reset}`,
|
|
698
|
+
);
|
|
641
699
|
} else {
|
|
642
700
|
console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history${reset}`);
|
|
643
701
|
}
|
|
@@ -741,7 +799,8 @@ async function main() {
|
|
|
741
799
|
emitTestCompleted({
|
|
742
800
|
runId,
|
|
743
801
|
name,
|
|
744
|
-
status:
|
|
802
|
+
status:
|
|
803
|
+
result.status === 'pass' ? 'pass' : result.status === 'suspicious' ? 'suspicious' : 'fail',
|
|
745
804
|
durationMs: result.duration * 1000,
|
|
746
805
|
error: result.error,
|
|
747
806
|
logDir,
|
|
@@ -757,11 +816,15 @@ async function main() {
|
|
|
757
816
|
if (flagKeep && result.projectName) {
|
|
758
817
|
writeFileSync(
|
|
759
818
|
persistentFile,
|
|
760
|
-
JSON.stringify(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
819
|
+
JSON.stringify(
|
|
820
|
+
{
|
|
821
|
+
projectName: result.projectName,
|
|
822
|
+
testFile: file,
|
|
823
|
+
createdAt: new Date().toISOString(),
|
|
824
|
+
},
|
|
825
|
+
null,
|
|
826
|
+
2,
|
|
827
|
+
) + '\n',
|
|
765
828
|
);
|
|
766
829
|
console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
|
|
767
830
|
console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
|
|
@@ -800,7 +863,9 @@ async function main() {
|
|
|
800
863
|
JSON.stringify({ status: result.status, duration: result.duration, error: result.error }),
|
|
801
864
|
);
|
|
802
865
|
|
|
803
|
-
try {
|
|
866
|
+
try {
|
|
867
|
+
execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 });
|
|
868
|
+
} catch {}
|
|
804
869
|
}
|
|
805
870
|
|
|
806
871
|
saveTiming(history);
|
|
@@ -820,7 +885,9 @@ async function main() {
|
|
|
820
885
|
for (const r of results) {
|
|
821
886
|
const icon = r.status === 'pass' ? '✓' : '✗';
|
|
822
887
|
const color = r.status === 'pass' ? green : red;
|
|
823
|
-
console.log(
|
|
888
|
+
console.log(
|
|
889
|
+
`${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`,
|
|
890
|
+
);
|
|
824
891
|
if (r.error) {
|
|
825
892
|
console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
|
|
826
893
|
}
|
|
@@ -832,7 +899,11 @@ async function main() {
|
|
|
832
899
|
|
|
833
900
|
writeFileSync(
|
|
834
901
|
join(resultsDir, 'summary.json'),
|
|
835
|
-
JSON.stringify(
|
|
902
|
+
JSON.stringify(
|
|
903
|
+
{ timestamp, total: testFiles.length, passed, failed, duration: suiteDuration },
|
|
904
|
+
null,
|
|
905
|
+
2,
|
|
906
|
+
),
|
|
836
907
|
);
|
|
837
908
|
|
|
838
909
|
const gitignore = join(testDir, '.gitignore');
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@celilo/registry-server",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.ts",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"start": "bun run src/index.ts",
|
|
9
|
-
"dev": "bun --watch src/index.ts",
|
|
10
|
-
"build:binary": "bun build --compile --target=bun-linux-x64 --outfile=dist/celilo-registry-server src/index.ts",
|
|
11
|
-
"test": "bun test"
|
|
12
|
-
},
|
|
13
|
-
"devDependencies": {
|
|
14
|
-
"bun-types": "latest",
|
|
15
|
-
"typescript": "^5"
|
|
16
|
-
}
|
|
17
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
|
|
3
|
-
|
|
4
|
-
describe('TokenAuth (package-scoped — ISS-0140)', () => {
|
|
5
|
-
test('empty specs → no tokens; nothing authorizes', () => {
|
|
6
|
-
const auth = new TokenAuth([]);
|
|
7
|
-
expect(auth.hasTokens()).toBe(false);
|
|
8
|
-
expect(auth.authorize('anything', 'caddy')).toBe(false);
|
|
9
|
-
expect(auth.isAdmin('anything')).toBe(false);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('admin-scoped token publishes ANY package', () => {
|
|
13
|
-
const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
|
|
14
|
-
expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
|
|
15
|
-
expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
|
|
16
|
-
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('package-scoped token publishes ONLY its package', () => {
|
|
20
|
-
const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
|
|
21
|
-
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
22
|
-
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
23
|
-
// A scoped token is NOT an admin token — it cannot mint.
|
|
24
|
-
expect(auth.isAdmin('luna-tok')).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('unknown token never authorizes', () => {
|
|
28
|
-
const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
|
|
29
|
-
expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('tokens are stored hashed, not in cleartext', () => {
|
|
33
|
-
const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
|
|
34
|
-
expect(JSON.stringify(auth)).not.toContain('secret-token');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('tolerates a Bearer prefix and surrounding whitespace', () => {
|
|
38
|
-
const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
|
|
39
|
-
expect(auth.authorize(' clean ', 'caddy')).toBe(true);
|
|
40
|
-
expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('empty header rejected', () => {
|
|
44
|
-
const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
|
|
45
|
-
expect(auth.authorize('', 'caddy')).toBe(false);
|
|
46
|
-
expect(auth.scopeOf('')).toBe(null);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('addHashed / removeHashed manage minted tokens at runtime', () => {
|
|
50
|
-
const auth = new TokenAuth([]);
|
|
51
|
-
const hash = hashToken('minted-tok');
|
|
52
|
-
auth.addHashed(hash, 'lunacycle');
|
|
53
|
-
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
|
|
54
|
-
auth.removeHashed(hash);
|
|
55
|
-
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
|
|
60
|
-
test('bare line → admin scope; "token pkg" line → scoped', () => {
|
|
61
|
-
process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
|
|
62
|
-
const auth = TokenAuth.fromEnv();
|
|
63
|
-
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
64
|
-
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
65
|
-
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
66
|
-
process.env.PUBLISH_TOKENS = '';
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('blank lines ignored', () => {
|
|
70
|
-
process.env.PUBLISH_TOKENS = '\n \nt1\n';
|
|
71
|
-
const auth = TokenAuth.fromEnv();
|
|
72
|
-
expect(auth.hasTokens()).toBe(true);
|
|
73
|
-
expect(auth.isAdmin('t1')).toBe(true);
|
|
74
|
-
process.env.PUBLISH_TOKENS = '';
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Package-scoped token auth (build-bus Phase 3, ISS-0140).
|
|
5
|
-
*
|
|
6
|
-
* Each token carries a SCOPE — either a single package name (the token may
|
|
7
|
-
* publish only that module) or the admin scope `*` (publish anything + mint
|
|
8
|
-
* scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in
|
|
9
|
-
* the auth set once the server starts.
|
|
10
|
-
*
|
|
11
|
-
* PUBLISH_TOKENS env (newline-separated), per line:
|
|
12
|
-
* <token> → admin scope `*` (publish any package, mint tokens)
|
|
13
|
-
* <token> <package> → scoped to that one package
|
|
14
|
-
*
|
|
15
|
-
* A bare `<token>` line is the admin/bootstrap token — this preserves the
|
|
16
|
-
* pre-scoping behavior (any configured token publishes anything) AND is the
|
|
17
|
-
* privilege that the `registry_publish` capability uses to mint per-repo
|
|
18
|
-
* scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed}
|
|
19
|
-
* (loaded from the persisted store) and removed via {@link removeHashed}.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** Admin scope: publish any package + mint scoped tokens. */
|
|
23
|
-
export const ADMIN_SCOPE = '*';
|
|
24
|
-
|
|
25
|
-
export interface TokenSpec {
|
|
26
|
-
token: string;
|
|
27
|
-
/** Package name, or {@link ADMIN_SCOPE}. */
|
|
28
|
-
scope: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */
|
|
32
|
-
export function hashToken(token: string): string {
|
|
33
|
-
return createHash('sha256').update(token.trim()).digest('hex');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */
|
|
37
|
-
function bareToken(header: string): string {
|
|
38
|
-
return header.replace(/^Bearer\s+/i, '').trim();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class TokenAuth {
|
|
42
|
-
/** hash → scope. */
|
|
43
|
-
private readonly scopeByHash = new Map<string, string>();
|
|
44
|
-
|
|
45
|
-
constructor(specs: TokenSpec[]) {
|
|
46
|
-
for (const s of specs) this.addRaw(s.token, s.scope);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is
|
|
51
|
-
* `<token>` (admin scope) or `<token> <package>` (scoped to one package).
|
|
52
|
-
*/
|
|
53
|
-
static fromEnv(): TokenAuth {
|
|
54
|
-
const raw = process.env.PUBLISH_TOKENS ?? '';
|
|
55
|
-
const specs: TokenSpec[] = [];
|
|
56
|
-
for (const line of raw.split('\n')) {
|
|
57
|
-
const trimmed = line.trim();
|
|
58
|
-
if (!trimmed) continue;
|
|
59
|
-
const [token, scope] = trimmed.split(/\s+/, 2);
|
|
60
|
-
specs.push({ token, scope: scope || ADMIN_SCOPE });
|
|
61
|
-
}
|
|
62
|
-
return new TokenAuth(specs);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Add a cleartext token (hashes it). Empty tokens are ignored. */
|
|
66
|
-
addRaw(rawToken: string, scope: string): void {
|
|
67
|
-
const t = rawToken.trim();
|
|
68
|
-
if (!t) return;
|
|
69
|
-
this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Add an already-hashed token (used when loading the persisted minted-token store). */
|
|
73
|
-
addHashed(hash: string, scope: string): void {
|
|
74
|
-
this.scopeByHash.set(hash, scope);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Remove a token by its hash (token revocation). */
|
|
78
|
-
removeHashed(hash: string): void {
|
|
79
|
-
this.scopeByHash.delete(hash);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** If no tokens are configured, the server treats every write as unauthorized. */
|
|
83
|
-
hasTokens(): boolean {
|
|
84
|
-
return this.scopeByHash.size > 0;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** The scope of a token (from an Authorization header value), or null if unknown. */
|
|
88
|
-
scopeOf(header: string): string | null {
|
|
89
|
-
const t = bareToken(header);
|
|
90
|
-
if (!t) return null;
|
|
91
|
-
return this.scopeByHash.get(hashToken(t)) ?? null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** True when the token may publish/yank the given package (admin or exact scope). */
|
|
95
|
-
authorize(header: string, pkg: string): boolean {
|
|
96
|
-
const scope = this.scopeOf(header);
|
|
97
|
-
if (scope === null) return false;
|
|
98
|
-
return scope === ADMIN_SCOPE || scope === pkg;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */
|
|
102
|
-
isAdmin(header: string): boolean {
|
|
103
|
-
return this.scopeOf(header) === ADMIN_SCOPE;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lockstep gate (ISS-0046): the registry-server's on-demand `walkDir` packager
|
|
3
|
-
* can't import the canonical node_modules-bundling rule at runtime (this server
|
|
4
|
-
* ships standalone, with no @celilo deps), so it carries a structural copy.
|
|
5
|
-
* This test imports the canonical rule directly (zero-dependency module, safe to
|
|
6
|
-
* pull in at test time from the workspace) and asserts walkDir's actual output
|
|
7
|
-
* conforms to it — so a drift in either copy fails here instead of shipping a
|
|
8
|
-
* broken `.netapp`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
12
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
// Canonical rule lives in apps/celilo; importing it cross-package is fine in a
|
|
16
|
-
// workspace test (it's not bundled into the standalone registry-sim image).
|
|
17
|
-
import { includeNodeModulesPath } from '../../../apps/celilo/src/module/packaging/package-rules';
|
|
18
|
-
import { walkDir } from './bootstrap';
|
|
19
|
-
|
|
20
|
-
describe('bootstrap walkDir stays in lockstep with the canonical packaging rule', () => {
|
|
21
|
-
let dir: string;
|
|
22
|
-
|
|
23
|
-
// Representative node_modules paths spanning both branches of the rule.
|
|
24
|
-
const nmFiles = [
|
|
25
|
-
'scripts/node_modules/@celilo/capabilities/src/dns-internal.ts',
|
|
26
|
-
'scripts/node_modules/tldts/index.js',
|
|
27
|
-
'scripts/node_modules/drizzle-orm/index.js',
|
|
28
|
-
'scripts/node_modules/.bin/tldts',
|
|
29
|
-
'node_modules/@celilo/capabilities/index.js',
|
|
30
|
-
'node_modules/lodash/index.js',
|
|
31
|
-
'node_modules/@celilo/e2e/index.js',
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
function write(rel: string): void {
|
|
35
|
-
const full = join(dir, rel);
|
|
36
|
-
mkdirSync(join(full, '..'), { recursive: true });
|
|
37
|
-
writeFileSync(full, 'x');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
dir = mkdtempSync(join(tmpdir(), 'celilo-bootstrap-rule-'));
|
|
42
|
-
write('manifest.yml');
|
|
43
|
-
write('scripts/on-install.ts');
|
|
44
|
-
for (const f of nmFiles) write(f);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
afterEach(() => {
|
|
48
|
-
try {
|
|
49
|
-
rmSync(dir, { recursive: true, force: true });
|
|
50
|
-
} catch {
|
|
51
|
-
/* ignore */
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("walkDir's node_modules decisions match includeNodeModulesPath", () => {
|
|
56
|
-
const bundled = new Set(walkDir(dir));
|
|
57
|
-
for (const rel of nmFiles) {
|
|
58
|
-
expect(bundled.has(rel)).toBe(includeNodeModulesPath(rel));
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('bundles scripts deps (tldts), drops .bin shims and non-scripts cruft', () => {
|
|
63
|
-
const bundled = new Set(walkDir(dir));
|
|
64
|
-
expect(bundled.has('scripts/node_modules/tldts/index.js')).toBe(true);
|
|
65
|
-
expect(bundled.has('scripts/node_modules/drizzle-orm/index.js')).toBe(true);
|
|
66
|
-
expect(bundled.has('scripts/node_modules/.bin/tldts')).toBe(false);
|
|
67
|
-
expect(bundled.has('node_modules/lodash/index.js')).toBe(false);
|
|
68
|
-
expect(bundled.has('node_modules/@celilo/e2e/index.js')).toBe(false);
|
|
69
|
-
expect(bundled.has('node_modules/@celilo/capabilities/index.js')).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
});
|