@openparachute/hub 0.7.7-rc.12 → 0.7.7-rc.13
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/package.json +1 -1
- package/src/__tests__/api-settings-root-redirect.test.ts +148 -5
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +285 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/install.test.ts +115 -35
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/api-settings-root-redirect.ts +95 -19
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +30 -19
- package/src/hub-server.ts +71 -3
- package/src/hub-settings.ts +137 -0
- package/src/root-serve.ts +156 -0
- package/src/service-spec.ts +3 -2
|
@@ -12,30 +12,38 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_MODE,
|
|
15
16
|
DEFAULT_ROOT_REDIRECT,
|
|
16
17
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
17
18
|
MODULE_INSTALL_CHANNELS,
|
|
19
|
+
PARACHUTE_HUB_ROOT_MODE_ENV,
|
|
18
20
|
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
19
21
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
20
22
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
23
|
+
ROOT_MODES,
|
|
21
24
|
SETUP_EXPOSE_MODES,
|
|
22
25
|
consumeFirstClientAutoApproveWindow,
|
|
23
26
|
deleteSetting,
|
|
24
27
|
getHubOrigin,
|
|
25
28
|
getModuleInstallChannel,
|
|
29
|
+
getRootMode,
|
|
26
30
|
getRootRedirect,
|
|
27
31
|
getSetting,
|
|
28
32
|
isFirstClientAutoApproveWindowOpen,
|
|
29
33
|
isModuleInstallChannel,
|
|
30
34
|
isNotesRedirectDisabled,
|
|
35
|
+
isRootMode,
|
|
31
36
|
isSafeRedirectPath,
|
|
32
37
|
isSetupExposeMode,
|
|
33
38
|
openFirstClientAutoApproveWindow,
|
|
39
|
+
resolveRootMode,
|
|
40
|
+
resolveRootModeDetailed,
|
|
34
41
|
resolveRootRedirect,
|
|
35
42
|
resolveRootRedirectDetailed,
|
|
36
43
|
setHubOrigin,
|
|
37
44
|
setModuleInstallChannel,
|
|
38
45
|
setNotesRedirectDisabled,
|
|
46
|
+
setRootMode,
|
|
39
47
|
setRootRedirect,
|
|
40
48
|
setSetting,
|
|
41
49
|
} from "../hub-settings.ts";
|
|
@@ -363,9 +371,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
363
371
|
// First read with env=rc — env wins, returns "rc". DB stays empty
|
|
364
372
|
// (no auto-write to DB when env is set, per the design that
|
|
365
373
|
// preserves the SPA's last-write as the env-unset fallback).
|
|
366
|
-
expect(
|
|
367
|
-
|
|
368
|
-
)
|
|
374
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
375
|
+
"rc",
|
|
376
|
+
);
|
|
369
377
|
// Second read with env=latest — env wins again (would have returned
|
|
370
378
|
// "rc" under the old DB-after-first-seed behavior).
|
|
371
379
|
expect(
|
|
@@ -386,9 +394,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
386
394
|
const db = openHubDb(hubDbPath(dir));
|
|
387
395
|
try {
|
|
388
396
|
// PARACHUTE_INSTALL_CHANNEL — canonical name (matches install.ts).
|
|
389
|
-
expect(
|
|
390
|
-
|
|
391
|
-
)
|
|
397
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
398
|
+
"rc",
|
|
399
|
+
);
|
|
392
400
|
// PARACHUTE_MODULE_CHANNEL — legacy alias, still recognized.
|
|
393
401
|
const db2 = openHubDb(join(mkdtempSync(join(tmpdir(), "phub-hsalt-")), "hub.db"));
|
|
394
402
|
try {
|
|
@@ -801,3 +809,99 @@ describe("hub-settings — root_redirect storage + resolution", () => {
|
|
|
801
809
|
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
810
|
});
|
|
803
811
|
});
|
|
812
|
+
|
|
813
|
+
describe("hub-settings — root_mode storage + resolution", () => {
|
|
814
|
+
let dir: string;
|
|
815
|
+
beforeEach(() => {
|
|
816
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-mode-"));
|
|
817
|
+
});
|
|
818
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
819
|
+
|
|
820
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
821
|
+
const silent = () => {};
|
|
822
|
+
|
|
823
|
+
test("isRootMode + ROOT_MODES", () => {
|
|
824
|
+
expect(ROOT_MODES).toEqual(["redirect", "serve-app"]);
|
|
825
|
+
expect(isRootMode("redirect")).toBe(true);
|
|
826
|
+
expect(isRootMode("serve-app")).toBe(true);
|
|
827
|
+
expect(isRootMode("serveapp")).toBe(false);
|
|
828
|
+
expect(isRootMode(42)).toBe(false);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("getRootMode round-trips serve-app via setRootMode", () => {
|
|
832
|
+
const db = openHubDb(hubDbPath(dir));
|
|
833
|
+
try {
|
|
834
|
+
expect(getRootMode(db)).toBeNull();
|
|
835
|
+
setRootMode(db, "serve-app");
|
|
836
|
+
expect(getRootMode(db)).toBe("serve-app");
|
|
837
|
+
} finally {
|
|
838
|
+
db.close();
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("setRootMode(null) and setRootMode('redirect') both clear the row (default = absence)", () => {
|
|
843
|
+
const db = openHubDb(hubDbPath(dir));
|
|
844
|
+
try {
|
|
845
|
+
setRootMode(db, "serve-app");
|
|
846
|
+
setRootMode(db, null);
|
|
847
|
+
expect(getRootMode(db)).toBeNull();
|
|
848
|
+
setRootMode(db, "serve-app");
|
|
849
|
+
setRootMode(db, "redirect"); // the default is stored as absence
|
|
850
|
+
expect(getRootMode(db)).toBeNull();
|
|
851
|
+
expect(getSetting(db, "root_mode")).toBeUndefined();
|
|
852
|
+
} finally {
|
|
853
|
+
db.close();
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("resolves to the redirect default when neither DB nor env is set", () => {
|
|
858
|
+
const db = openHubDb(hubDbPath(dir));
|
|
859
|
+
try {
|
|
860
|
+
expect(resolveRootMode(db, { env: noEnv })).toBe(DEFAULT_ROOT_MODE);
|
|
861
|
+
expect(resolveRootModeDetailed(db, { env: noEnv })).toEqual({
|
|
862
|
+
value: "redirect",
|
|
863
|
+
source: "default",
|
|
864
|
+
});
|
|
865
|
+
} finally {
|
|
866
|
+
db.close();
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("env override applies when no DB row; DB overrides env (DB is tier-1)", () => {
|
|
871
|
+
const db = openHubDb(hubDbPath(dir));
|
|
872
|
+
try {
|
|
873
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" };
|
|
874
|
+
expect(resolveRootModeDetailed(db, { env })).toEqual({ value: "serve-app", source: "env" });
|
|
875
|
+
setRootMode(db, "serve-app");
|
|
876
|
+
// A DB "serve-app" over an env "redirect" resolves to db.
|
|
877
|
+
expect(
|
|
878
|
+
resolveRootModeDetailed(db, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "redirect" } }),
|
|
879
|
+
).toEqual({ value: "serve-app", source: "db" });
|
|
880
|
+
} finally {
|
|
881
|
+
db.close();
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("an invalid DB row / env value is ignored → falls back to the redirect default", () => {
|
|
886
|
+
const db = openHubDb(hubDbPath(dir));
|
|
887
|
+
try {
|
|
888
|
+
setSetting(db, "root_mode", "bogus"); // hand-edited row bypassing write validation
|
|
889
|
+
expect(resolveRootMode(db, { env: noEnv, warn: silent })).toBe("redirect");
|
|
890
|
+
expect(getRootMode(db)).toBeNull(); // getRootMode also guards the invalid value
|
|
891
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "nonsense" };
|
|
892
|
+
expect(resolveRootModeDetailed(db, { env, warn: silent })).toEqual({
|
|
893
|
+
value: "redirect",
|
|
894
|
+
source: "default",
|
|
895
|
+
});
|
|
896
|
+
} finally {
|
|
897
|
+
db.close();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
902
|
+
expect(resolveRootMode(null, { env: noEnv })).toBe("redirect");
|
|
903
|
+
expect(resolveRootMode(null, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" } })).toBe(
|
|
904
|
+
"serve-app",
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
@@ -5,8 +5,11 @@ import { join } from "node:path";
|
|
|
5
5
|
import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
|
|
6
6
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
7
|
import {
|
|
8
|
+
PARACHUTE_HUB_ROOT_MODE_ENV,
|
|
8
9
|
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
10
|
+
getRootMode,
|
|
9
11
|
getRootRedirect,
|
|
12
|
+
setRootMode,
|
|
10
13
|
setRootRedirect,
|
|
11
14
|
} from "../hub-settings.ts";
|
|
12
15
|
import { findService, upsertService } from "../services-manifest.ts";
|
|
@@ -2417,31 +2420,38 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
|
|
|
2417
2420
|
});
|
|
2418
2421
|
});
|
|
2419
2422
|
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
//
|
|
2423
|
+
// `parachute install app` defaults the hub's origin root to SERVE the app
|
|
2424
|
+
// (root_mode = serve-app) on first install — SET-IF-UNSET ONLY, and only when
|
|
2425
|
+
// the root behavior is entirely pristine default (no mode row/env AND no
|
|
2426
|
+
// redirect row/env). Supersedes the prior hub-parity P5 behavior (which wrote
|
|
2427
|
+
// root_redirect = /app/). Every test here injects `rootRedirectDb` (a hub.db
|
|
2428
|
+
// opened in the same disposable tempdir `makeTempPath()` already isolates) so no
|
|
2429
|
+
// real `~/.parachute/hub.db` is ever touched — never drive this against the
|
|
2425
2430
|
// operator's live install.
|
|
2426
|
-
describe("app-only
|
|
2427
|
-
// The write gates on `
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
let
|
|
2431
|
+
describe("app-only serve-app set-if-unset default", () => {
|
|
2432
|
+
// The write gates on the RESOLVED mode + redirect being `default` (no DB row,
|
|
2433
|
+
// no env). Neutralize any ambient PARACHUTE_HUB_ROOT_MODE / _REDIRECT (Aaron's
|
|
2434
|
+
// box, CI) so the default-tier tests are deterministic; the env-set tests
|
|
2435
|
+
// below manage their own values inside their own bodies.
|
|
2436
|
+
let savedRedirectEnv: string | undefined;
|
|
2437
|
+
let savedModeEnv: string | undefined;
|
|
2432
2438
|
beforeEach(() => {
|
|
2433
|
-
|
|
2439
|
+
savedRedirectEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2440
|
+
savedModeEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2434
2441
|
delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2442
|
+
delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2435
2443
|
});
|
|
2436
2444
|
afterEach(() => {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2445
|
+
for (const [name, saved] of [
|
|
2446
|
+
[PARACHUTE_HUB_ROOT_REDIRECT_ENV, savedRedirectEnv],
|
|
2447
|
+
[PARACHUTE_HUB_ROOT_MODE_ENV, savedModeEnv],
|
|
2448
|
+
] as const) {
|
|
2449
|
+
if (saved === undefined) delete process.env[name];
|
|
2450
|
+
else process.env[name] = saved;
|
|
2441
2451
|
}
|
|
2442
2452
|
});
|
|
2443
2453
|
|
|
2444
|
-
test("fresh install (
|
|
2454
|
+
test("fresh install (pristine root) flips root_mode to serve-app", async () => {
|
|
2445
2455
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2446
2456
|
try {
|
|
2447
2457
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2458,8 +2468,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2458
2468
|
log: (l) => logs.push(l),
|
|
2459
2469
|
});
|
|
2460
2470
|
expect(code).toBe(0);
|
|
2461
|
-
expect(
|
|
2462
|
-
|
|
2471
|
+
expect(getRootMode(db)).toBe("serve-app");
|
|
2472
|
+
// The redirect row is NOT written — serve-app takes over `/` directly.
|
|
2473
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
2474
|
+
expect(logs.join("\n")).toMatch(/front page.*now serves the Parachute app/);
|
|
2463
2475
|
} finally {
|
|
2464
2476
|
db.close();
|
|
2465
2477
|
}
|
|
@@ -2468,7 +2480,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2468
2480
|
}
|
|
2469
2481
|
});
|
|
2470
2482
|
|
|
2471
|
-
test("a pre-set root_redirect is left alone —
|
|
2483
|
+
test("a pre-set root_redirect is left alone — mode NOT flipped", async () => {
|
|
2472
2484
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2473
2485
|
try {
|
|
2474
2486
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2486,13 +2498,43 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2486
2498
|
log: (l) => logs.push(l),
|
|
2487
2499
|
});
|
|
2488
2500
|
expect(code).toBe(0);
|
|
2489
|
-
// The operator's prior choice survives byte-for-byte.
|
|
2501
|
+
// The operator's prior choice survives byte-for-byte; no mode flip.
|
|
2490
2502
|
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2503
|
+
expect(getRootMode(db)).toBeNull();
|
|
2504
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2505
|
+
} finally {
|
|
2506
|
+
db.close();
|
|
2507
|
+
}
|
|
2508
|
+
} finally {
|
|
2509
|
+
cleanup();
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
test("an existing install with the old /app/ redirect keeps redirecting (back-compat)", async () => {
|
|
2514
|
+
// A hub that ran the PRIOR app-install code has root_redirect = /app/ in its
|
|
2515
|
+
// DB (source = db → not default). Re-running `install app` must NOT flip it
|
|
2516
|
+
// to serve-app — it keeps 302-ing to /app/, exactly as before. This is the
|
|
2517
|
+
// "existing installs unchanged" guarantee.
|
|
2518
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2519
|
+
try {
|
|
2520
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2521
|
+
const logs: string[] = [];
|
|
2522
|
+
try {
|
|
2523
|
+
setRootRedirect(db, "/app/");
|
|
2524
|
+
const code = await install("app", {
|
|
2525
|
+
runner: async () => 0,
|
|
2526
|
+
manifestPath: path,
|
|
2527
|
+
configDir,
|
|
2528
|
+
startService: async () => 0,
|
|
2529
|
+
isLinked: () => false,
|
|
2530
|
+
portProbe: async () => false,
|
|
2531
|
+
rootRedirectDb: db,
|
|
2532
|
+
log: (l) => logs.push(l),
|
|
2533
|
+
});
|
|
2534
|
+
expect(code).toBe(0);
|
|
2535
|
+
expect(getRootMode(db)).toBeNull();
|
|
2536
|
+
expect(getRootRedirect(db)).toBe("/app/");
|
|
2537
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2496
2538
|
} finally {
|
|
2497
2539
|
db.close();
|
|
2498
2540
|
}
|
|
@@ -2501,7 +2543,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2501
2543
|
}
|
|
2502
2544
|
});
|
|
2503
2545
|
|
|
2504
|
-
test("installing a different module never touches
|
|
2546
|
+
test("installing a different module never touches the root settings", async () => {
|
|
2505
2547
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2506
2548
|
try {
|
|
2507
2549
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2518,6 +2560,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2518
2560
|
});
|
|
2519
2561
|
expect(code).toBe(0);
|
|
2520
2562
|
expect(getRootRedirect(db)).toBeNull();
|
|
2563
|
+
expect(getRootMode(db)).toBeNull();
|
|
2521
2564
|
} finally {
|
|
2522
2565
|
db.close();
|
|
2523
2566
|
}
|
|
@@ -2526,13 +2569,12 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2526
2569
|
}
|
|
2527
2570
|
});
|
|
2528
2571
|
|
|
2529
|
-
test("an ENV-configured redirect (no DB row) is left alone —
|
|
2572
|
+
test("an ENV-configured redirect (no DB row) is left alone — no mode flip (N1)", async () => {
|
|
2530
2573
|
// Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
|
|
2531
|
-
// not a DB row.
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
2534
|
-
// `
|
|
2535
|
-
// env tier. So: env set → no DB row written.
|
|
2574
|
+
// not a DB row. Flipping to serve-app would silently override their
|
|
2575
|
+
// configured landing (serve-app wins over redirect at `/`). The gate reads
|
|
2576
|
+
// the RESOLVED redirect source; with the env set that's `env`, not
|
|
2577
|
+
// `default`, so we leave it alone.
|
|
2536
2578
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2537
2579
|
const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2538
2580
|
try {
|
|
@@ -2551,9 +2593,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2551
2593
|
log: (l) => logs.push(l),
|
|
2552
2594
|
});
|
|
2553
2595
|
expect(code).toBe(0);
|
|
2554
|
-
// No
|
|
2596
|
+
// No mode row written — the env-configured landing survives.
|
|
2597
|
+
expect(getRootMode(db)).toBeNull();
|
|
2555
2598
|
expect(getRootRedirect(db)).toBeNull();
|
|
2556
|
-
expect(logs.join("\n")).not.toMatch(/now
|
|
2599
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2557
2600
|
} finally {
|
|
2558
2601
|
db.close();
|
|
2559
2602
|
}
|
|
@@ -2567,6 +2610,43 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2567
2610
|
}
|
|
2568
2611
|
});
|
|
2569
2612
|
|
|
2613
|
+
test("an ENV-configured mode (no DB row) is left alone — no clobber", async () => {
|
|
2614
|
+
// PARACHUTE_HUB_ROOT_MODE=redirect on a container that deliberately wants
|
|
2615
|
+
// the redirect front door. The gate sees the resolved mode source as `env`,
|
|
2616
|
+
// not `default`, so it does NOT write a serve-app row.
|
|
2617
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2618
|
+
const prevEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2619
|
+
try {
|
|
2620
|
+
process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = "redirect";
|
|
2621
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2622
|
+
const logs: string[] = [];
|
|
2623
|
+
try {
|
|
2624
|
+
const code = await install("app", {
|
|
2625
|
+
runner: async () => 0,
|
|
2626
|
+
manifestPath: path,
|
|
2627
|
+
configDir,
|
|
2628
|
+
startService: async () => 0,
|
|
2629
|
+
isLinked: () => false,
|
|
2630
|
+
portProbe: async () => false,
|
|
2631
|
+
rootRedirectDb: db,
|
|
2632
|
+
log: (l) => logs.push(l),
|
|
2633
|
+
});
|
|
2634
|
+
expect(code).toBe(0);
|
|
2635
|
+
expect(getRootMode(db)).toBeNull();
|
|
2636
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2637
|
+
} finally {
|
|
2638
|
+
db.close();
|
|
2639
|
+
}
|
|
2640
|
+
} finally {
|
|
2641
|
+
if (prevEnv === undefined) {
|
|
2642
|
+
delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2643
|
+
} else {
|
|
2644
|
+
process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = prevEnv;
|
|
2645
|
+
}
|
|
2646
|
+
cleanup();
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2570
2650
|
test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
|
|
2571
2651
|
// Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
|
|
2572
2652
|
// install.ts: a test with a tempdir manifestPath and no explicit opt-in
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `src/root-serve.ts` — serving the Parachute app AT the origin root
|
|
3
|
+
* (`root_mode = serve-app`).
|
|
4
|
+
*
|
|
5
|
+
* Covers `serveAppAtRoot` (the static file-or-SPA-shell decision, Accept-gated,
|
|
6
|
+
* reserved-prefix-guarded, traversal-guarded) and `makeAppDistResolver` (success
|
|
7
|
+
* memoization + failure NON-caching for dynamic recovery).
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { makeAppDistResolver, serveAppAtRoot } from "../root-serve.ts";
|
|
14
|
+
|
|
15
|
+
let dist: string;
|
|
16
|
+
let root: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
root = mkdtempSync(join(tmpdir(), "phub-root-serve-"));
|
|
20
|
+
dist = join(root, "dist");
|
|
21
|
+
mkdirSync(join(dist, "assets"), { recursive: true });
|
|
22
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>App</title>");
|
|
23
|
+
writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('app')");
|
|
24
|
+
writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
|
|
25
|
+
writeFileSync(join(dist, "sw.js"), "self.addEventListener('install',()=>{})");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function get(pathname: string, accept?: string): Request {
|
|
33
|
+
return new Request(`http://localhost${pathname}`, {
|
|
34
|
+
method: "GET",
|
|
35
|
+
headers: accept ? { accept } : {},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("serveAppAtRoot", () => {
|
|
40
|
+
test("serves index.html at `/`", async () => {
|
|
41
|
+
const res = serveAppAtRoot(dist, get("/", "text/html"), "/");
|
|
42
|
+
expect(res).not.toBeNull();
|
|
43
|
+
expect(res?.headers.get("content-type")).toContain("text/html");
|
|
44
|
+
expect(await res?.text()).toContain("<title>App</title>");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("serves an existing asset with an inferred content-type", async () => {
|
|
48
|
+
const res = serveAppAtRoot(dist, get("/assets/index-abc.js", "*/*"), "/assets/index-abc.js");
|
|
49
|
+
expect(res).not.toBeNull();
|
|
50
|
+
expect(res?.headers.get("content-type") ?? "").toMatch(/javascript/);
|
|
51
|
+
expect(await res?.text()).toContain("console.log");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("serves the PWA manifest with the application/manifest+json override", () => {
|
|
55
|
+
const res = serveAppAtRoot(dist, get("/manifest.webmanifest", "*/*"), "/manifest.webmanifest");
|
|
56
|
+
expect(res).not.toBeNull();
|
|
57
|
+
expect(res?.headers.get("content-type")).toBe("application/manifest+json");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("serves the service worker as a real file (not the SPA shell)", async () => {
|
|
61
|
+
const res = serveAppAtRoot(dist, get("/sw.js", "*/*"), "/sw.js");
|
|
62
|
+
expect(res).not.toBeNull();
|
|
63
|
+
expect(await res?.text()).toContain("addEventListener");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("SPA fallback: an unclaimed HTML deep link gets index.html", async () => {
|
|
67
|
+
const res = serveAppAtRoot(dist, get("/some/app/route", "text/html"), "/some/app/route");
|
|
68
|
+
expect(res).not.toBeNull();
|
|
69
|
+
expect(await res?.text()).toContain("<title>App</title>");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("non-HTML unclaimed request → null (branded 404 tail)", () => {
|
|
73
|
+
expect(serveAppAtRoot(dist, get("/nope.json", "application/json"), "/nope.json")).toBeNull();
|
|
74
|
+
// A missing asset fetched with Accept: */* is not an HTML navigation → null.
|
|
75
|
+
expect(serveAppAtRoot(dist, get("/assets/missing.js", "*/*"), "/assets/missing.js")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("non-GET → null (a non-GET unclaimed path keeps its default)", () => {
|
|
79
|
+
const post = new Request("http://localhost/", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { accept: "text/html" },
|
|
82
|
+
});
|
|
83
|
+
expect(serveAppAtRoot(dist, post, "/")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reserved hub/protocol prefixes keep the branded 404 even for HTML nav", () => {
|
|
87
|
+
for (const p of ["/api/bogus", "/oauth/typo", "/.well-known/nope"]) {
|
|
88
|
+
expect(serveAppAtRoot(dist, get(p, "text/html"), p)).toBeNull();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("path traversal cannot escape dist", () => {
|
|
93
|
+
// A crafted encoded traversal joins outside dist → falls through (null).
|
|
94
|
+
const p = "/assets/..%2f..%2f..%2fetc%2fpasswd";
|
|
95
|
+
expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a resolved dist whose index.html vanished → null (no broken shell)", () => {
|
|
99
|
+
rmSync(join(dist, "index.html"));
|
|
100
|
+
expect(serveAppAtRoot(dist, get("/", "text/html"), "/")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("malformed percent-encoding → null (no URIError thrown → branded 404, not 500)", () => {
|
|
104
|
+
// decodeURIComponent throws URIError on a bad escape; the guard must swallow
|
|
105
|
+
// it and fall through so the dispatch never 500s on a garbage asset URL.
|
|
106
|
+
for (const p of ["/assets/%ZZ", "/foo%", "/%E0%A4%A", "/bar%2"]) {
|
|
107
|
+
expect(() => serveAppAtRoot(dist, get(p, "*/*"), p)).not.toThrow();
|
|
108
|
+
expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
|
|
109
|
+
}
|
|
110
|
+
// Even an HTML navigation with bad encoding falls through (redirect-mode
|
|
111
|
+
// parity) rather than shelling.
|
|
112
|
+
expect(serveAppAtRoot(dist, get("/foo%", "text/html"), "/foo%")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("makeAppDistResolver", () => {
|
|
117
|
+
test("returns the resolved dist and memoizes a success", () => {
|
|
118
|
+
let calls = 0;
|
|
119
|
+
const resolve = makeAppDistResolver(() => {
|
|
120
|
+
calls++;
|
|
121
|
+
return dist;
|
|
122
|
+
});
|
|
123
|
+
expect(resolve()).toBe(dist);
|
|
124
|
+
expect(resolve()).toBe(dist);
|
|
125
|
+
expect(calls).toBe(1); // success cached — only resolved once
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("does NOT cache a failure — recovers once the app is installed", () => {
|
|
129
|
+
let installed = false;
|
|
130
|
+
const resolve = makeAppDistResolver(() => {
|
|
131
|
+
if (!installed) throw new Error("not installed");
|
|
132
|
+
return dist;
|
|
133
|
+
});
|
|
134
|
+
expect(resolve()).toBeNull(); // not installed yet
|
|
135
|
+
expect(resolve()).toBeNull(); // still probing, still null
|
|
136
|
+
installed = true;
|
|
137
|
+
expect(resolve()).toBe(dist); // picked up without a restart
|
|
138
|
+
});
|
|
139
|
+
});
|