@celilo/e2e 0.20.0 → 0.20.2

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.
@@ -0,0 +1,139 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { isNetappCurrent } from './netapp-staleness';
14
+
15
+ let dir: string;
16
+ let src: string;
17
+ let netapp: string;
18
+ const scratch: string[] = [];
19
+
20
+ beforeAll(() => {
21
+ dir = mkdtempSync(join(tmpdir(), 'netapp-stale-'));
22
+ scratch.push(dir);
23
+ src = join(dir, 'src');
24
+ netapp = join(dir, 'out.netapp');
25
+ mkdirSync(src);
26
+ });
27
+
28
+ afterAll(() => {
29
+ for (const path of scratch) rmSync(path, { recursive: true, force: true });
30
+ });
31
+
32
+ function writeSource(name: string, contents: string): string {
33
+ const target = join(src, name);
34
+ writeFileSync(target, contents);
35
+ return target;
36
+ }
37
+
38
+ test('no netapp yet: not current, even over an empty tree', () => {
39
+ expect(existsSync(netapp)).toBe(false);
40
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
41
+ });
42
+
43
+ test('netapp newer than every source file: current (the skip case)', () => {
44
+ writeSource('main.sh', 'echo hello');
45
+ writeFileSync(netapp, 'netapp-bytes');
46
+ // The netapp was written after the source, so repackaging would produce
47
+ // the same bytes. build-infra must skip this module.
48
+ expect(isNetappCurrent(netapp, dir)).toBe(true);
49
+ });
50
+
51
+ test('recurrence gate (celilo#1258): touching a source file makes the netapp stale', () => {
52
+ // Touch, don't rewrite: proves the check reads mtimes, not contents.
53
+ const touched = writeSource('touched.txt', 'x');
54
+ const later = new Date(Date.now() + 60_000);
55
+ utimesSync(touched, later, later);
56
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
57
+ });
58
+
59
+ test('netapp older than a source edit: stale', () => {
60
+ const older = new Date(Date.now() - 120_000);
61
+ utimesSync(netapp, older, older);
62
+ expect(isNetappCurrent(netapp, dir)).toBe(false);
63
+ });
64
+
65
+ test('a rebuilt binlink dir never marks the netapp stale (the bun install shape)', () => {
66
+ // Fresh tree: shared state above would leak src/'s own dir mtime into this
67
+ // assertion, and src/ is a shipped path whose mtime must count.
68
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-binlinks-'));
69
+ scratch.push(tree);
70
+ const moduleDir = join(tree, 'mod');
71
+ const binDir = join(moduleDir, 'e2e', 'node_modules', '.bin');
72
+ mkdirSync(binDir, { recursive: true });
73
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
74
+ symlinkSync('../real-tool', join(binDir, 'tool'));
75
+ const staged = join(tree, 'staged.netapp');
76
+ writeFileSync(staged, 'netapp-bytes');
77
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
78
+
79
+ // bun install recreates the binlink: the symlink file AND the .bin
80
+ // directory move past the netapp. Neither ships (see classifyModulePath
81
+ // and includeNodeModulesPath), so the netapp stays current. This is the
82
+ // dir-level case the file-level exclusion alone misses, probed 2026-09-08:
83
+ // `-not -path '*/node_modules/.bin/*'` does not match `.bin` itself, and
84
+ // creating an entry moves the dir's mtime.
85
+ rmSync(join(binDir, 'tool'));
86
+ symlinkSync('../real-tool', join(binDir, 'tool'));
87
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
88
+ });
89
+
90
+ test('a change confined to the module e2e/ tree never marks the netapp stale', () => {
91
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-e2e-'));
92
+ scratch.push(tree);
93
+ const moduleDir = join(tree, 'mod');
94
+ mkdirSync(join(moduleDir, 'e2e'), { recursive: true });
95
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
96
+ writeFileSync(join(moduleDir, 'e2e', 'suite.test.ts'), 'test("x", () => {});');
97
+ const staged = join(tree, 'staged.netapp');
98
+ writeFileSync(staged, 'netapp-bytes');
99
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
100
+
101
+ // An e2e-only edit: the file and its parent dir are both unshipped, so
102
+ // the netapp stays current. A change to main.sh would NOT be excluded —
103
+ // covered by the recurrence gate above.
104
+ const later = new Date(Date.now() + 60_000);
105
+ const e2eFile = join(moduleDir, 'e2e', 'new.test.ts');
106
+ writeFileSync(e2eFile, 'test("y", () => {});');
107
+ utimesSync(e2eFile, later, later);
108
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
109
+ });
110
+
111
+ test('a change to a SHIPPED bin-closure path marks the netapp stale', () => {
112
+ // scripts/node_modules minus .bin is the hook runtime closure the packager
113
+ // ships in full (ISS-0046). A change there must repackage, so the
114
+ // exclusion cannot be as broad as "anything under node_modules".
115
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-hookrt-'));
116
+ scratch.push(tree);
117
+ const moduleDir = join(tree, 'mod');
118
+ const hookDep = join(moduleDir, 'scripts', 'node_modules', 'tldts');
119
+ mkdirSync(hookDep, { recursive: true });
120
+ writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
121
+ writeFileSync(join(hookDep, 'index.js'), 'module.exports = {};');
122
+ const staged = join(tree, 'staged.netapp');
123
+ writeFileSync(staged, 'netapp-bytes');
124
+ expect(isNetappCurrent(staged, moduleDir)).toBe(true);
125
+
126
+ const later = new Date(Date.now() + 60_000);
127
+ const depFile = join(hookDep, 'index.js');
128
+ writeFileSync(depFile, 'module.exports = { changed: true };');
129
+ utimesSync(depFile, later, later);
130
+ expect(isNetappCurrent(staged, moduleDir)).toBe(false);
131
+ });
132
+
133
+ test('a nonexistent source tree repackages (a check that cannot answer never reuses)', () => {
134
+ const tree = mkdtempSync(join(tmpdir(), 'netapp-missing-'));
135
+ scratch.push(tree);
136
+ const staged = join(tree, 'staged.netapp');
137
+ writeFileSync(staged, 'netapp-bytes');
138
+ expect(isNetappCurrent(staged, join(tree, 'does-not-exist'))).toBe(false);
139
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Staleness helper for build-infra's `packageNetapp` (cli/build.ts).
3
+ *
4
+ * Before celilo#1258 build-infra repackaged all 37 modules on every run
5
+ * (about 5 minutes to reach a 38 second test) because nothing reused a
6
+ * current .netapp. publishModule's reuse of the staged netapp is a separate
7
+ * fix that landed from ce-nvd8 (bf05c946) and keeps its own probe.
8
+ */
9
+
10
+ import { execSync } from 'node:child_process';
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /**
14
+ * True when `netappPath` exists and nothing that ships in the package has
15
+ * changed under `sourceDir` since it was written.
16
+ *
17
+ * `find -newer -quit` asks the filesystem instead of walking in JS and stops
18
+ * at the first newer file. A staleness check that cannot answer repackages:
19
+ * reusing on an inconclusive result is how a test silently runs against a
20
+ * stale module.
21
+ *
22
+ * Only paths the package actually contains can make it stale. `e2e/` is
23
+ * excluded from a module package wholesale and `node_modules/.bin` is excluded
24
+ * from the hook runtime closure — see `classifyModulePath` and
25
+ * `includeNodeModulesPath` in apps/celilo/src/module/packaging/, which are the
26
+ * authority. They are restated rather than imported because packages/e2e has
27
+ * no import path into apps/celilo, and registry-server's bootstrap.ts already
28
+ * carries the same duplication for the same reason.
29
+ *
30
+ * Both subtrees are excluded at the DIRECTORY level as well as the file
31
+ * level (path suffixes `e2e` and `node_modules/.bin`, with and without a
32
+ * trailing component): a directory's mtime moves whenever an entry inside
33
+ * it is created or replaced, so `bun install` recreating the binlinks in a
34
+ * module's `e2e/node_modules/.bin` would otherwise mark every module
35
+ * permanently dirty through the dir entry alone.
36
+ *
37
+ * Getting these wrong is safe in one direction only, and it is this one:
38
+ * counting a non-packaged path makes us repackage needlessly (slow), while
39
+ * MISSING a packaged path would reuse a stale netapp (wrong). Every exclusion
40
+ * here is a path the packager does not ship, so it cannot cause the latter.
41
+ */
42
+ export function isNetappCurrent(netappPath: string, sourceDir: string): boolean {
43
+ if (!existsSync(netappPath)) return false;
44
+ // Each exclude needs both the bare dir and its contents: `find -newer`
45
+ // matches the directory's own mtime too, and creating or replacing an
46
+ // entry inside moves it.
47
+ const excludes = [
48
+ "-not -path '*/e2e'",
49
+ "-not -path '*/e2e/*'",
50
+ "-not -path '*/node_modules/.bin'",
51
+ "-not -path '*/node_modules/.bin/*'",
52
+ ];
53
+ try {
54
+ const newer = execSync(
55
+ `find ${JSON.stringify(sourceDir)} -newer ${JSON.stringify(netappPath)} ${excludes.join(' ')} -print -quit`,
56
+ { encoding: 'utf-8', timeout: 30_000 },
57
+ ).trim();
58
+ return newer === '';
59
+ } catch {
60
+ return false; // a check that cannot answer repackages
61
+ }
62
+ }
@@ -38,6 +38,10 @@ async function ensureModuleDeps(hostPath: string): Promise<void> {
38
38
  export class NetworkBuilder {
39
39
  private config: NetworkConfig = {
40
40
  topology: 'default',
41
+ // The production topology: celilo-mgr sits on its own control-plane
42
+ // network. A suite that genuinely needs the single-network legacy
43
+ // topology opts out with `.managementZone('internal')` and says why.
44
+ managementZone: 'secure-mgmt',
41
45
  dmzMachines: [],
42
46
  appMachines: [],
43
47
  secureMachines: [],
@@ -72,9 +76,11 @@ export class NetworkBuilder {
72
76
 
73
77
  /**
74
78
  * Place the celilo management container in a given zone. Defaults to
75
- * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
76
- * (the production topology), so a test can exercise celilo reaching and
77
- * resolving for a fleet from OFF the internal LAN.
79
+ * `secure-mgmt` (the production topology, celilo-mgr on its own
80
+ * control-plane network). `internal` opts back into the legacy
81
+ * single-network topology, where "trusted subnet == network.internal.subnet"
82
+ * comes out true by construction — only for a suite that genuinely tests a
83
+ * behaviour on that topology, and says so.
78
84
  */
79
85
  managementZone(zone: 'internal' | 'secure-mgmt'): this {
80
86
  this.config.managementZone = zone;
@@ -278,6 +284,16 @@ export class NetworkBuilder {
278
284
  return this;
279
285
  }
280
286
 
287
+ /**
288
+ * The config this builder will hand to `startNetwork`, read back for tests
289
+ * that assert on the DEFAULTS rather than construct a literal. Without this
290
+ * a default is invisible to every gate (the literal-shaped configs in the
291
+ * unit tests never exercise it).
292
+ */
293
+ snapshotConfig(): Readonly<NetworkConfig> {
294
+ return { ...this.config };
295
+ }
296
+
281
297
  async start(): Promise<NetworkHandle> {
282
298
  // Pre-flight: ensure mounted module directories have deps installed.
283
299
  // Without this, `module import` tries to `bun install` inside Docker
@@ -28,7 +28,16 @@
28
28
  */
29
29
 
30
30
  import { cpSync, existsSync, rmSync } from 'node:fs';
31
- import { join } from 'node:path';
31
+ import { basename, join } from 'node:path';
32
+
33
+ // @psbanka - 2026-09: Test files are excluded from the bundle (ce-62u1). A
34
+ // bundled test copy is one directory deeper than its source, so
35
+ // workspace-relative imports like '../../../apps/...' resolve one level short
36
+ // and fail. The bundle exists to serve the sim image, which runs no tests, so
37
+ // the tests belong to the source package only.
38
+ function isTestFile(path: string): boolean {
39
+ return /\.test\.tsx?$/.test(basename(path));
40
+ }
32
41
 
33
42
  /**
34
43
  * Ensure <pkgDir>/registry-server/ contains a fresh copy of the
@@ -76,6 +85,9 @@ function ensureSiblingBundle(pkgDir: string, name: string, entries: string[]): v
76
85
 
77
86
  rmSync(bundle, { recursive: true, force: true });
78
87
  for (const entry of entries) {
79
- cpSync(join(upstream, entry), join(bundle, entry), { recursive: true });
88
+ cpSync(join(upstream, entry), join(bundle, entry), {
89
+ recursive: true,
90
+ filter: (src) => !isTestFile(src),
91
+ });
80
92
  }
81
93
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gate on the wiring of the --keep exit path (celilo#1313).
3
+ *
4
+ * `cele2e run <suite> --keep` kept the per-test project but tore the SHARED
5
+ * infrastructure down anyway, so the kept stack had no DNS, no CA and no
6
+ * registry — half a system, in the one mode the debugging method prescribes.
7
+ * The mechanism was a wiring bug: the end-of-run `stopSharedInfra()` call
8
+ * carried no flagKeep guard, while the per-test path right above it was
9
+ * guarded. Like the sibling exit-cleanup gate, the unit gate is on the wiring;
10
+ * the behavioral proof (one cheap suite run with --keep, shared containers
11
+ * inspected afterwards) is an e2e run and needs the rig.
12
+ *
13
+ * Read the source instead of importing the runner: main() runs at import time
14
+ * (module top-level), so importing it would execute a docker run.
15
+ */
16
+
17
+ import { expect, test } from 'bun:test';
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const SRC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'runner.ts'), 'utf-8');
23
+
24
+ /** The end-of-run block, from the results summary to the last-run write. */
25
+ function endOfRunBlock(): string {
26
+ return SRC.slice(SRC.indexOf('saveTiming(history);\n saveBlockTiming(blockTiming);'));
27
+ }
28
+
29
+ test('end-of-run stopSharedInfra is guarded by flagKeep (celilo#1313)', () => {
30
+ // Today the call ran unconditionally, so a --keep run destroyed exactly the
31
+ // shared infra the kept stack depends on.
32
+ const block = endOfRunBlock();
33
+ const guardAt = block.indexOf('if (flagKeep)');
34
+ const stopAt = block.indexOf('stopSharedInfra()');
35
+ expect(guardAt).toBeGreaterThan(-1);
36
+ expect(stopAt).toBeGreaterThan(-1);
37
+ // The unconditional call sat BEFORE any guard; after the fix the guard
38
+ // precedes it, so the call is inside the flagKeep branch.
39
+ expect(guardAt).toBeLessThan(stopAt);
40
+ });
41
+
42
+ test('a --keep exit names what was kept, not just what stopped (celilo#1313)', () => {
43
+ // "Stopping shared infrastructure..." printed while the user had asked to
44
+ // keep things, and nothing said DNS and certs would be missing. The kept
45
+ // path must name the shared pieces (DNS, CA, registry) and the one teardown
46
+ // command that reaps them.
47
+ const block = endOfRunBlock();
48
+ const keepPath = block.slice(block.indexOf('if (flagKeep)'), block.indexOf('stopSharedInfra()'));
49
+ expect(keepPath).toContain('DNS');
50
+ expect(keepPath).toContain('cele2e down');
51
+ });
52
+
53
+ test('the kept lock marks under --keep even when no project was captured', () => {
54
+ // With shared infra left up, a --keep run that failed to capture a project
55
+ // (startNetwork failure) still leaves a live stack. The kept lock is the
56
+ // instrument that says so: the next run's live-stack guard reads the same
57
+ // state, but the refusal reads as contention rather than a deliberate kept
58
+ // stack unless the lock marks. Idempotent with the per-test markKept().
59
+ const block = endOfRunBlock();
60
+ const keepPath = block.slice(block.indexOf('if (flagKeep)'), block.indexOf('} else'));
61
+ expect(keepPath).toContain('markKept()');
62
+ });
63
+
64
+ test('SIGINT still stops shared infra unconditionally (abort is not a kept stack)', () => {
65
+ // Pinned as intended behavior: Ctrl-C is an abort, the persistent project
66
+ // was never written and no kept lock was marked, so tearing the shared
67
+ // pieces down is right. The next run's live-stack guard names the remedy
68
+ // for anything the abort left live.
69
+ const sigint = SRC.slice(SRC.indexOf("process.on('SIGINT'"), SRC.indexOf('for (let i = 0;'));
70
+ expect(sigint).toContain('stopSharedInfra()');
71
+ expect(sigint).not.toContain('flagKeep');
72
+ });
package/src/runner.ts CHANGED
@@ -1080,8 +1080,25 @@ async function main() {
1080
1080
  saveTiming(history);
1081
1081
  saveBlockTiming(blockTiming);
1082
1082
 
1083
- console.log(`${dim}Stopping shared infrastructure...${reset}`);
1084
- stopSharedInfra();
1083
+ // A --keep run keeps the WHOLE stack: the per-test project (persistent file
1084
+ // written in the loop) and the shared infra here. Tearing the shared pieces
1085
+ // down under --keep gave a kept stack with no name resolution and no certs
1086
+ // (celilo#1313) — half a system, in the mode the debugging method
1087
+ // prescribes. The exit stays one command: `cele2e down` sweeps every
1088
+ // celilo-e2e-* container, network and volume by name filter (bin/e2e-down)
1089
+ // and releases the kept lock, so the kept shared infra costs nothing extra
1090
+ // to reap. markKept() here too: with shared infra left up, a run that
1091
+ // captured no project still leaves a live stack, and an unmarked lock makes
1092
+ // the next run's live-stack refusal read as contention instead of a
1093
+ // deliberate kept stack. Idempotent with the per-test markKept().
1094
+ if (flagKeep) {
1095
+ markKept();
1096
+ console.log(`${dim}Keeping shared infrastructure (DNS, CA, registry) for debugging.${reset}`);
1097
+ console.log(`${dim} Tear down everything (project + shared infra): cele2e down${reset}`);
1098
+ } else {
1099
+ console.log(`${dim}Stopping shared infrastructure...${reset}`);
1100
+ stopSharedInfra();
1101
+ }
1085
1102
 
1086
1103
  const suiteDuration = Math.floor((Date.now() - suiteStart) / 1000);
1087
1104
 
@@ -22,8 +22,10 @@ import {
22
22
  import {
23
23
  type DockerReader,
24
24
  LiveStackError,
25
+ type ProcessProbe,
25
26
  findLiveE2eStack,
26
27
  realDocker,
28
+ realProcessProbe,
27
29
  refuseOnLiveStack,
28
30
  } from './live-stack';
29
31
  import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from './registry-bundle';
@@ -101,8 +103,9 @@ export function startupCleanup(
101
103
  e2eDir: string,
102
104
  docker: DockerReader = realDocker,
103
105
  lock: () => LockStatus = lockStatus,
106
+ processes: ProcessProbe = realProcessProbe,
104
107
  ): void {
105
- const refusal = findLiveE2eStack(docker, lock);
108
+ const refusal = findLiveE2eStack(docker, lock, processes);
106
109
  if (refusal) throw new LiveStackError(refusal);
107
110
  console.log(
108
111
  '[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete',
@@ -173,7 +176,9 @@ export async function ensureSharedInfra(): Promise<void> {
173
176
  // DNS check failed — restart shared infra. The restart tears down the
174
177
  // running stack, so the live-stack guard fires first (celilo#1297): a
175
178
  // stack that is up is never force-removed, even when its DNS is wedged.
176
- // The operator clears it with `cele2e down` and re-runs. This retires
179
+ // The operator clears it with the docker removal named in the refusal
180
+ // (a bare `cele2e down` does not exist on ephemeral CI hosts —
181
+ // celilo#1314) and re-runs. This retires
177
182
  // the automatic DNS-restart self-heal by decision (peba, 2026-09-07):
178
183
  // fail loudly naming the holder beats silently clobbering a stack that
179
184
  // might be another session's — the #1297 incident rode exactly this
@@ -50,6 +50,17 @@ export const SOURCE_LABEL = 'computer.celilo.e2e.source';
50
50
  */
51
51
  export const PUBLISHED_FINGERPRINT_PREFIX = 'published:';
52
52
 
53
+ /**
54
+ * Value stamped by a CONSUMER-mode bake: `cele2e build-infra` run from an
55
+ * installed `@celilo/e2e` with no monorepo checkout. The CLI comes from the sim
56
+ * registry's freshly staged tarballs, so it is neither real npm nor this tree,
57
+ * and no tree fingerprint exists to record. The version is the honest
58
+ * attribution. Deliberately NOT the published prefix: that one tells the
59
+ * freshness check there is nothing to be stale against, which would suppress a
60
+ * real warning if such an image were later inspected from a checkout.
61
+ */
62
+ export const CONSUMER_FINGERPRINT_PREFIX = 'consumer:';
63
+
53
64
  /**
54
65
  * Repo-relative paths whose content reaches the baked CLI.
55
66
  *
package/src/types.ts CHANGED
@@ -120,14 +120,16 @@ export interface NetworkConfig {
120
120
  */
121
121
  signalRelease: boolean;
122
122
  /**
123
- * Which zone the celilo management container sits in. Defaults to `internal`
124
- * — the single-network topology every existing test uses.
123
+ * Which zone the celilo management container sits in. Defaults to
124
+ * `secure-mgmt` — the production topology, celilo-mgr on its own
125
+ * control-plane network. (It used to default to `internal`, the
126
+ * single-network topology, and that default is why the control-plane-network
127
+ * bug reached production: with management always on `internal`, the hardcoded
128
+ * "trusted subnet == network.internal.subnet" assumption was accidentally
129
+ * true in every test.)
125
130
  *
126
- * `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
127
- * the production topology and the one the suite could not express before. That
128
- * gap is why the control-plane-network bug reached production: with management
129
- * always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
130
- * assumption was accidentally true in every test.
131
+ * A suite that genuinely needs the legacy single-network topology sets
132
+ * `internal` EXPLICITLY and says why in a comment.
131
133
  */
132
134
  managementZone?: 'internal' | 'secure-mgmt';
133
135
  /**
@@ -290,6 +292,18 @@ export interface NetworkHandle {
290
292
  */
291
293
  moduleHost(moduleId: string): Promise<ModuleHost>;
292
294
 
295
+ /**
296
+ * The IPv4 address a module's deploy recorded, from `celilo module where
297
+ * --json` (the `module_systems` inventory).
298
+ *
299
+ * Replaces `grep target_ip` over the module's `generated/` tree, which D4
300
+ * of control-plane-stops-building-modules deletes after every successful
301
+ * deploy (celilo#1334). The inventory is the deploy's own durable record of
302
+ * the address, so this works on the machine pool and the container service
303
+ * alike. Throws when the deploy recorded nothing.
304
+ */
305
+ targetIp(moduleId: string): Promise<string>;
306
+
293
307
  /**
294
308
  * Tell celilo where its own control plane lives, and wait until the box is
295
309
  * usable again.
@@ -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,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
- });