@openparachute/hub 0.5.2 → 0.5.9-rc.6
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__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
7
7
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
-
import { findServiceUpstream, findVaultUpstream, hubFetch } from "../hub-server.ts";
|
|
8
|
+
import { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hub-server.ts";
|
|
9
9
|
import { pidPath } from "../process-state.ts";
|
|
10
10
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
11
11
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -57,7 +57,7 @@ describe("hubFetch routing", () => {
|
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("/hub.html serves the same file as /", async () => {
|
|
60
|
+
test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
|
|
61
61
|
const h = makeHarness();
|
|
62
62
|
try {
|
|
63
63
|
writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
|
|
@@ -69,6 +69,58 @@ describe("hubFetch routing", () => {
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
73
|
+
// The dynamic path takes over from the static disk file the moment a
|
|
74
|
+
// DB is configured. With no session cookie, we still render — just
|
|
75
|
+
// with the "Sign in" affordance.
|
|
76
|
+
const h = makeHarness();
|
|
77
|
+
try {
|
|
78
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
79
|
+
try {
|
|
80
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
83
|
+
const body = await res.text();
|
|
84
|
+
expect(body).toContain('class="auth-indicator"');
|
|
85
|
+
expect(body).toContain("Sign in");
|
|
86
|
+
expect(body).not.toContain("Signed in as");
|
|
87
|
+
} finally {
|
|
88
|
+
db.close();
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
h.cleanup();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
96
|
+
const h = makeHarness();
|
|
97
|
+
try {
|
|
98
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
99
|
+
try {
|
|
100
|
+
const { createUser } = await import("../users.ts");
|
|
101
|
+
const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
|
|
102
|
+
"../sessions.ts"
|
|
103
|
+
);
|
|
104
|
+
const user = await createUser(db, "aaron", "pw");
|
|
105
|
+
const session = createSession(db, { userId: user.id });
|
|
106
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
107
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
|
|
108
|
+
expect(res.status).toBe(200);
|
|
109
|
+
const body = await res.text();
|
|
110
|
+
expect(body).toContain("Signed in as");
|
|
111
|
+
expect(body).toContain("aaron");
|
|
112
|
+
expect(body).toContain('action="/logout"');
|
|
113
|
+
expect(body).toContain('name="__csrf"');
|
|
114
|
+
// CSRF cookie was minted on the response (no prior cookie present).
|
|
115
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
|
|
116
|
+
} finally {
|
|
117
|
+
db.close();
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
h.cleanup();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
72
124
|
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
73
125
|
const h = makeHarness();
|
|
74
126
|
try {
|
|
@@ -194,6 +246,105 @@ describe("hubFetch routing", () => {
|
|
|
194
246
|
}
|
|
195
247
|
});
|
|
196
248
|
|
|
249
|
+
// Phase D consumer-side: each non-vault service entry's
|
|
250
|
+
// module.json:uiUrl + displayName ride through to doc.services. The
|
|
251
|
+
// discovery page (`/`) reads them to render data-driven Service tiles.
|
|
252
|
+
test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
|
|
253
|
+
const h = makeHarness();
|
|
254
|
+
try {
|
|
255
|
+
const notesEntry: ServiceEntry = {
|
|
256
|
+
name: "parachute-notes",
|
|
257
|
+
port: 5173,
|
|
258
|
+
paths: ["/notes"],
|
|
259
|
+
health: "/health",
|
|
260
|
+
version: "0.0.1",
|
|
261
|
+
installDir: "/fake/notes",
|
|
262
|
+
};
|
|
263
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
264
|
+
const res = await hubFetch(h.dir, {
|
|
265
|
+
manifestPath: h.manifestPath,
|
|
266
|
+
readModuleManifest: async () => ({
|
|
267
|
+
name: "notes",
|
|
268
|
+
manifestName: "parachute-notes",
|
|
269
|
+
kind: "frontend",
|
|
270
|
+
port: 5173,
|
|
271
|
+
paths: ["/notes"],
|
|
272
|
+
health: "/health",
|
|
273
|
+
uiUrl: "/notes",
|
|
274
|
+
displayName: "Notes",
|
|
275
|
+
}),
|
|
276
|
+
})(req("/.well-known/parachute.json"));
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
const body = (await res.json()) as {
|
|
279
|
+
services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
|
|
280
|
+
};
|
|
281
|
+
const svc = body.services.find((s) => s.name === "parachute-notes");
|
|
282
|
+
expect(svc?.uiUrl).toMatch(/\/notes$/);
|
|
283
|
+
expect(svc?.displayName).toBe("Notes");
|
|
284
|
+
} finally {
|
|
285
|
+
h.cleanup();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
|
|
290
|
+
const h = makeHarness();
|
|
291
|
+
try {
|
|
292
|
+
const notesEntry: ServiceEntry = {
|
|
293
|
+
name: "parachute-notes",
|
|
294
|
+
port: 5173,
|
|
295
|
+
paths: ["/notes"],
|
|
296
|
+
health: "/health",
|
|
297
|
+
version: "0.0.1",
|
|
298
|
+
installDir: "/fake/notes",
|
|
299
|
+
};
|
|
300
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
301
|
+
const res = await hubFetch(h.dir, {
|
|
302
|
+
manifestPath: h.manifestPath,
|
|
303
|
+
readModuleManifest: async () => ({
|
|
304
|
+
name: "notes",
|
|
305
|
+
manifestName: "parachute-notes",
|
|
306
|
+
kind: "frontend",
|
|
307
|
+
port: 5173,
|
|
308
|
+
paths: ["/notes"],
|
|
309
|
+
health: "/health",
|
|
310
|
+
// no uiUrl declared — discovery page will skip the tile.
|
|
311
|
+
}),
|
|
312
|
+
})(req("/.well-known/parachute.json"));
|
|
313
|
+
const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
|
|
314
|
+
expect(body.services[0]).not.toHaveProperty("uiUrl");
|
|
315
|
+
} finally {
|
|
316
|
+
h.cleanup();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
|
|
321
|
+
const h = makeHarness();
|
|
322
|
+
try {
|
|
323
|
+
const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
|
|
324
|
+
writeManifest({ services: [vaultWithDir] }, h.manifestPath);
|
|
325
|
+
// The fake module.json declares uiUrl, but vault is supposed to be
|
|
326
|
+
// skipped by loadServiceUiMetadata (it has its own managementUrl
|
|
327
|
+
// path). So doc.services[vault] should NOT carry uiUrl.
|
|
328
|
+
const res = await hubFetch(h.dir, {
|
|
329
|
+
manifestPath: h.manifestPath,
|
|
330
|
+
readModuleManifest: async () => ({
|
|
331
|
+
name: "vault",
|
|
332
|
+
manifestName: "parachute-vault",
|
|
333
|
+
kind: "api",
|
|
334
|
+
port: 1940,
|
|
335
|
+
paths: ["/vault/default"],
|
|
336
|
+
health: "/health",
|
|
337
|
+
uiUrl: "/should-be-ignored",
|
|
338
|
+
}),
|
|
339
|
+
})(req("/.well-known/parachute.json"));
|
|
340
|
+
const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
|
|
341
|
+
const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
|
|
342
|
+
expect(vaultSvc).not.toHaveProperty("uiUrl");
|
|
343
|
+
} finally {
|
|
344
|
+
h.cleanup();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
197
348
|
// The bug this PR fixes: `parachute vault create techne` updates
|
|
198
349
|
// services.json but the old code only re-derived parachute.json on
|
|
199
350
|
// `parachute expose`. With the dynamic build, the second GET reflects
|
|
@@ -398,15 +549,12 @@ describe("hubFetch routing", () => {
|
|
|
398
549
|
}
|
|
399
550
|
});
|
|
400
551
|
|
|
401
|
-
// SPA
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// Same bundle at both mounts. Build base is /vault/, so asset URLs are
|
|
407
|
-
// origin-absolute and resolve regardless of which mount served the HTML.
|
|
552
|
+
// SPA mount after hub#231: single `/admin/*` mount serves vault
|
|
553
|
+
// provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
|
|
554
|
+
// SPA URLs are 301-redirected; the per-vault content proxy at
|
|
555
|
+
// `/vault/<name>/*` stays where it is.
|
|
408
556
|
|
|
409
|
-
test("/
|
|
557
|
+
test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
|
|
410
558
|
const h = makeHarness();
|
|
411
559
|
try {
|
|
412
560
|
const dist = join(h.dir, "dist");
|
|
@@ -414,7 +562,7 @@ describe("hubFetch routing", () => {
|
|
|
414
562
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
415
563
|
writeManifest({ services: [] }, h.manifestPath);
|
|
416
564
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
417
|
-
req("/
|
|
565
|
+
req("/admin/vaults"),
|
|
418
566
|
);
|
|
419
567
|
expect(res.status).toBe(200);
|
|
420
568
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -424,10 +572,7 @@ describe("hubFetch routing", () => {
|
|
|
424
572
|
}
|
|
425
573
|
});
|
|
426
574
|
|
|
427
|
-
test("/
|
|
428
|
-
// Routing-order check: `new` isn't a known vault, so proxyToVault
|
|
429
|
-
// returns undefined and we fall through to the SPA shell. The router
|
|
430
|
-
// takes over client-side and renders the NewVault form.
|
|
575
|
+
test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
|
|
431
576
|
const h = makeHarness();
|
|
432
577
|
try {
|
|
433
578
|
const dist = join(h.dir, "dist");
|
|
@@ -435,7 +580,7 @@ describe("hubFetch routing", () => {
|
|
|
435
580
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
436
581
|
writeManifest({ services: [] }, h.manifestPath);
|
|
437
582
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
438
|
-
req("/
|
|
583
|
+
req("/admin/vaults/new"),
|
|
439
584
|
);
|
|
440
585
|
expect(res.status).toBe(200);
|
|
441
586
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -445,7 +590,34 @@ describe("hubFetch routing", () => {
|
|
|
445
590
|
}
|
|
446
591
|
});
|
|
447
592
|
|
|
448
|
-
test("/
|
|
593
|
+
test("/admin/permissions serves the SPA shell", async () => {
|
|
594
|
+
const h = makeHarness();
|
|
595
|
+
try {
|
|
596
|
+
const dist = join(h.dir, "dist");
|
|
597
|
+
mkdirIfMissing(dist);
|
|
598
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
599
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
|
|
600
|
+
expect(res.status).toBe(200);
|
|
601
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
602
|
+
} finally {
|
|
603
|
+
h.cleanup();
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("/admin/tokens serves the SPA shell", async () => {
|
|
608
|
+
const h = makeHarness();
|
|
609
|
+
try {
|
|
610
|
+
const dist = join(h.dir, "dist");
|
|
611
|
+
mkdirIfMissing(dist);
|
|
612
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
613
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
|
|
614
|
+
expect(res.status).toBe(200);
|
|
615
|
+
} finally {
|
|
616
|
+
h.cleanup();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("/admin/assets/*.js is served with the matching content-type", async () => {
|
|
449
621
|
const h = makeHarness();
|
|
450
622
|
try {
|
|
451
623
|
const dist = join(h.dir, "dist");
|
|
@@ -456,7 +628,7 @@ describe("hubFetch routing", () => {
|
|
|
456
628
|
writeFileSync(join(assets, "main.js"), "console.log('hi');");
|
|
457
629
|
writeManifest({ services: [] }, h.manifestPath);
|
|
458
630
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
459
|
-
req("/
|
|
631
|
+
req("/admin/assets/main.js"),
|
|
460
632
|
);
|
|
461
633
|
expect(res.status).toBe(200);
|
|
462
634
|
expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
|
|
@@ -466,14 +638,14 @@ describe("hubFetch routing", () => {
|
|
|
466
638
|
}
|
|
467
639
|
});
|
|
468
640
|
|
|
469
|
-
test("/
|
|
641
|
+
test("/admin/* returns 503 with build hint when dist is missing", async () => {
|
|
470
642
|
const h = makeHarness();
|
|
471
643
|
try {
|
|
472
644
|
writeManifest({ services: [] }, h.manifestPath);
|
|
473
645
|
const res = await hubFetch(h.dir, {
|
|
474
646
|
spaDistDir: join(h.dir, "missing"),
|
|
475
647
|
manifestPath: h.manifestPath,
|
|
476
|
-
})(req("/
|
|
648
|
+
})(req("/admin/vaults"));
|
|
477
649
|
expect(res.status).toBe(503);
|
|
478
650
|
expect(await res.text()).toContain("bun run build");
|
|
479
651
|
} finally {
|
|
@@ -481,92 +653,194 @@ describe("hubFetch routing", () => {
|
|
|
481
653
|
}
|
|
482
654
|
});
|
|
483
655
|
|
|
484
|
-
test("/
|
|
656
|
+
test("/admin/vaults rejects non-GET methods with 405", async () => {
|
|
485
657
|
const h = makeHarness();
|
|
486
658
|
try {
|
|
487
659
|
const dist = join(h.dir, "dist");
|
|
488
660
|
mkdirIfMissing(dist);
|
|
489
661
|
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
490
|
-
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
662
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
663
|
+
req("/admin/vaults", { method: "POST" }),
|
|
664
|
+
);
|
|
491
665
|
expect(res.status).toBe(405);
|
|
492
666
|
} finally {
|
|
493
667
|
h.cleanup();
|
|
494
668
|
}
|
|
495
669
|
});
|
|
496
670
|
|
|
497
|
-
|
|
671
|
+
// 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
|
|
672
|
+
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
673
|
+
// dispatch — operator bookmarks landing on any of these still work.
|
|
674
|
+
|
|
675
|
+
test("301: /vault → /admin/vaults", async () => {
|
|
498
676
|
const h = makeHarness();
|
|
499
677
|
try {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/hub/permissions"));
|
|
504
|
-
expect(res.status).toBe(200);
|
|
505
|
-
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
506
|
-
expect(await res.text()).toContain("<div id=root>");
|
|
678
|
+
const res = await hubFetch(h.dir)(req("/vault"));
|
|
679
|
+
expect(res.status).toBe(301);
|
|
680
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
507
681
|
} finally {
|
|
508
682
|
h.cleanup();
|
|
509
683
|
}
|
|
510
684
|
});
|
|
511
685
|
|
|
512
|
-
test("/
|
|
686
|
+
test("301: /vault/new → /admin/vaults/new", async () => {
|
|
513
687
|
const h = makeHarness();
|
|
514
688
|
try {
|
|
515
|
-
const res = await hubFetch(h.dir
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
expect(res.status).toBe(503);
|
|
519
|
-
expect(await res.text()).toContain("bun run build");
|
|
689
|
+
const res = await hubFetch(h.dir)(req("/vault/new"));
|
|
690
|
+
expect(res.status).toBe(301);
|
|
691
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
520
692
|
} finally {
|
|
521
693
|
h.cleanup();
|
|
522
694
|
}
|
|
523
695
|
});
|
|
524
696
|
|
|
525
|
-
test("/
|
|
697
|
+
test("301: /vault preserves query string", async () => {
|
|
526
698
|
const h = makeHarness();
|
|
527
699
|
try {
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
532
|
-
req("/hub/permissions", { method: "POST" }),
|
|
533
|
-
);
|
|
534
|
-
expect(res.status).toBe(405);
|
|
700
|
+
const res = await hubFetch(h.dir)(req("/vault?next=foo"));
|
|
701
|
+
expect(res.status).toBe(301);
|
|
702
|
+
expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
|
|
535
703
|
} finally {
|
|
536
704
|
h.cleanup();
|
|
537
705
|
}
|
|
538
706
|
});
|
|
539
707
|
|
|
540
|
-
test("/hub/vaults
|
|
541
|
-
//
|
|
542
|
-
//
|
|
708
|
+
test("301: /hub/vaults → /admin/vaults (chain through the rename)", async () => {
|
|
709
|
+
// The /hub/vaults redirect predates #231 — it used to land at /vault.
|
|
710
|
+
// Now it lands at the final /admin/vaults so old bookmarks don't bounce
|
|
711
|
+
// through two redirects.
|
|
543
712
|
const h = makeHarness();
|
|
544
713
|
try {
|
|
545
714
|
const res = await hubFetch(h.dir)(req("/hub/vaults"));
|
|
546
715
|
expect(res.status).toBe(301);
|
|
547
|
-
expect(res.headers.get("location")).toBe("/
|
|
716
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
548
717
|
} finally {
|
|
549
718
|
h.cleanup();
|
|
550
719
|
}
|
|
551
720
|
});
|
|
552
721
|
|
|
553
|
-
test("/hub/vaults/new
|
|
722
|
+
test("301: /hub/vaults/new → /admin/vaults/new", async () => {
|
|
554
723
|
const h = makeHarness();
|
|
555
724
|
try {
|
|
556
725
|
const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
|
|
557
726
|
expect(res.status).toBe(301);
|
|
558
|
-
expect(res.headers.get("location")).toBe("/
|
|
727
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
559
728
|
} finally {
|
|
560
729
|
h.cleanup();
|
|
561
730
|
}
|
|
562
731
|
});
|
|
563
732
|
|
|
564
|
-
test("/hub/vaults/* preserves the query string
|
|
733
|
+
test("301: /hub/vaults/* preserves the query string", async () => {
|
|
565
734
|
const h = makeHarness();
|
|
566
735
|
try {
|
|
567
736
|
const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
|
|
568
737
|
expect(res.status).toBe(301);
|
|
569
|
-
expect(res.headers.get("location")).toBe("/
|
|
738
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
|
|
739
|
+
} finally {
|
|
740
|
+
h.cleanup();
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("301: /hub/permissions → /admin/permissions", async () => {
|
|
745
|
+
const h = makeHarness();
|
|
746
|
+
try {
|
|
747
|
+
const res = await hubFetch(h.dir)(req("/hub/permissions"));
|
|
748
|
+
expect(res.status).toBe(301);
|
|
749
|
+
expect(res.headers.get("location")).toBe("/admin/permissions");
|
|
750
|
+
} finally {
|
|
751
|
+
h.cleanup();
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("301: /hub/tokens → /admin/tokens", async () => {
|
|
756
|
+
const h = makeHarness();
|
|
757
|
+
try {
|
|
758
|
+
const res = await hubFetch(h.dir)(req("/hub/tokens"));
|
|
759
|
+
expect(res.status).toBe(301);
|
|
760
|
+
expect(res.headers.get("location")).toBe("/admin/tokens");
|
|
761
|
+
} finally {
|
|
762
|
+
h.cleanup();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("301: /hub bare → /admin/vaults", async () => {
|
|
767
|
+
const h = makeHarness();
|
|
768
|
+
try {
|
|
769
|
+
const res = await hubFetch(h.dir)(req("/hub"));
|
|
770
|
+
expect(res.status).toBe(301);
|
|
771
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
772
|
+
} finally {
|
|
773
|
+
h.cleanup();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Login surface rename redirects (auth-UX cleanup): /admin/login and
|
|
778
|
+
// /admin/logout 301 to /login and /logout. Path-only test — the
|
|
779
|
+
// handlers themselves are exercised through the existing
|
|
780
|
+
// handleAdminLoginGet/Post + handleAdminLogoutPost test files.
|
|
781
|
+
test("301: /admin/login → /login", async () => {
|
|
782
|
+
const h = makeHarness();
|
|
783
|
+
try {
|
|
784
|
+
const res = await hubFetch(h.dir)(req("/admin/login"));
|
|
785
|
+
expect(res.status).toBe(301);
|
|
786
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
787
|
+
} finally {
|
|
788
|
+
h.cleanup();
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("301: /admin/login preserves the next= query param", async () => {
|
|
793
|
+
const h = makeHarness();
|
|
794
|
+
try {
|
|
795
|
+
const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
|
|
796
|
+
expect(res.status).toBe(301);
|
|
797
|
+
expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
|
|
798
|
+
} finally {
|
|
799
|
+
h.cleanup();
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
|
|
804
|
+
const h = makeHarness();
|
|
805
|
+
try {
|
|
806
|
+
const res = await hubFetch(h.dir)(req("/admin/config"));
|
|
807
|
+
expect(res.status).toBe(301);
|
|
808
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
809
|
+
} finally {
|
|
810
|
+
h.cleanup();
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("301: /admin/config/<name> → /admin/vaults", async () => {
|
|
815
|
+
const h = makeHarness();
|
|
816
|
+
try {
|
|
817
|
+
const res = await hubFetch(h.dir)(req("/admin/config/vault"));
|
|
818
|
+
expect(res.status).toBe(301);
|
|
819
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
820
|
+
} finally {
|
|
821
|
+
h.cleanup();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("301: /admin/logout → /logout", async () => {
|
|
826
|
+
const h = makeHarness();
|
|
827
|
+
try {
|
|
828
|
+
const res = await hubFetch(h.dir)(req("/admin/logout"));
|
|
829
|
+
expect(res.status).toBe(301);
|
|
830
|
+
expect(res.headers.get("location")).toBe("/logout");
|
|
831
|
+
} finally {
|
|
832
|
+
h.cleanup();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
|
|
837
|
+
const h = makeHarness();
|
|
838
|
+
try {
|
|
839
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
840
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
841
|
+
req("/hub/unknown-thing"),
|
|
842
|
+
);
|
|
843
|
+
expect(res.status).toBe(404);
|
|
570
844
|
} finally {
|
|
571
845
|
h.cleanup();
|
|
572
846
|
}
|
|
@@ -591,10 +865,11 @@ describe("hubFetch routing", () => {
|
|
|
591
865
|
["/oauth/register", { method: "POST" }],
|
|
592
866
|
["/oauth/revoke", { method: "POST" }],
|
|
593
867
|
["/vaults", { method: "POST" }],
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
["/
|
|
868
|
+
// /login + /logout — canonical names since the auth-UX rename;
|
|
869
|
+
// /admin/login + /admin/logout 301-redirect to here (separate
|
|
870
|
+
// tests pin the redirects themselves).
|
|
871
|
+
["/login", { method: "POST" }],
|
|
872
|
+
["/logout", { method: "POST" }],
|
|
598
873
|
["/admin/host-admin-token", { method: "GET" }],
|
|
599
874
|
];
|
|
600
875
|
for (const [path, init] of cases) {
|
|
@@ -746,6 +1021,31 @@ describe("findVaultUpstream (#144)", () => {
|
|
|
746
1021
|
expect(m?.mount).toBe("/vault/inner");
|
|
747
1022
|
expect(m?.port).toBe(1941);
|
|
748
1023
|
});
|
|
1024
|
+
|
|
1025
|
+
// #197: a services.json entry written with a trailing slash on the mount
|
|
1026
|
+
// path (e.g. `paths: ["/vault/default/"]`) used to only match the exact
|
|
1027
|
+
// pathname `/vault/default/` and silently drop every sub-path because
|
|
1028
|
+
// `pathname.startsWith("/vault/default//")` is always false. Normalize
|
|
1029
|
+
// trailing slashes before comparison so sub-paths route correctly.
|
|
1030
|
+
test("trailing-slash mount path matches sub-paths (#197)", () => {
|
|
1031
|
+
const trailing: ServiceEntry = {
|
|
1032
|
+
name: "parachute-vault",
|
|
1033
|
+
port: 1940,
|
|
1034
|
+
paths: ["/vault/default/"],
|
|
1035
|
+
health: "/vault/default/health",
|
|
1036
|
+
version: "0.4.0",
|
|
1037
|
+
};
|
|
1038
|
+
const exact = findVaultUpstream([trailing], "/vault/default");
|
|
1039
|
+
expect(exact?.port).toBe(1940);
|
|
1040
|
+
// mount is reported normalized (trailing slash stripped) so callers
|
|
1041
|
+
// computing `pathname.slice(match.mount.length)` get the same answer
|
|
1042
|
+
// regardless of how the entry was written on disk.
|
|
1043
|
+
expect(exact?.mount).toBe("/vault/default");
|
|
1044
|
+
|
|
1045
|
+
const sub = findVaultUpstream([trailing], "/vault/default/notes/abc");
|
|
1046
|
+
expect(sub?.port).toBe(1940);
|
|
1047
|
+
expect(sub?.mount).toBe("/vault/default");
|
|
1048
|
+
});
|
|
749
1049
|
});
|
|
750
1050
|
|
|
751
1051
|
describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
@@ -999,15 +1299,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
999
1299
|
}
|
|
1000
1300
|
});
|
|
1001
1301
|
|
|
1002
|
-
test("single-segment /vault/<name> picks proxy when registered,
|
|
1302
|
+
test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
|
|
1003
1303
|
// Two cases share one fixture so the contrast is explicit:
|
|
1004
1304
|
// - `/vault/default` is registered → proxy answers (200, JSON tag).
|
|
1005
|
-
// - `/vault/nonexistent` has no match →
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1305
|
+
// - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
|
|
1306
|
+
// fallback under /vault since hub#231 moved the admin SPA to
|
|
1307
|
+
// /admin/*; the /vault/<name>/* slot is now exclusively the
|
|
1308
|
+
// per-vault content proxy).
|
|
1008
1309
|
// This is the routing-order seam #173 introduced — proxy is consulted
|
|
1009
|
-
// before the SPA fallback
|
|
1010
|
-
// vault claims the path.
|
|
1310
|
+
// before the 404; the SPA fallback that used to live here is gone.
|
|
1011
1311
|
const h = makeHarness();
|
|
1012
1312
|
const upstream = startUpstream("default-vault");
|
|
1013
1313
|
try {
|
|
@@ -1040,10 +1340,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1040
1340
|
expect(body.tag).toBe("default-vault");
|
|
1041
1341
|
expect(body.pathname).toBe("/vault/default");
|
|
1042
1342
|
|
|
1043
|
-
const
|
|
1044
|
-
expect(
|
|
1045
|
-
expect(shelled.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
1046
|
-
expect(await shelled.text()).toContain("<div id=root>");
|
|
1343
|
+
const notFound = await fetcher(req("/vault/nonexistent"));
|
|
1344
|
+
expect(notFound.status).toBe(404);
|
|
1047
1345
|
} finally {
|
|
1048
1346
|
upstream.stop();
|
|
1049
1347
|
h.cleanup();
|
|
@@ -1144,6 +1442,62 @@ describe("findServiceUpstream (#182)", () => {
|
|
|
1144
1442
|
expect(findServiceUpstream(services, "/scribe-admin")).toBeUndefined();
|
|
1145
1443
|
expect(findServiceUpstream(services, "/scribe-admin/foo")).toBeUndefined();
|
|
1146
1444
|
});
|
|
1445
|
+
|
|
1446
|
+
// #197: a services.json entry written with a trailing slash on the mount
|
|
1447
|
+
// path (e.g. `paths: ["/notes/"]`) used to only match the exact pathname
|
|
1448
|
+
// `/notes/` and silently drop every sub-path because
|
|
1449
|
+
// `pathname.startsWith("/notes//")` is always false. Notes blank-screen
|
|
1450
|
+
// on Aaron's box (2026-05-08) was the operator-visible symptom: the SPA
|
|
1451
|
+
// shell loaded but every `/notes/assets/*.js` 404'd. Normalize trailing
|
|
1452
|
+
// slashes before comparison.
|
|
1453
|
+
test("trailing-slash mount path matches sub-paths (#197)", () => {
|
|
1454
|
+
const services: ServiceEntry[] = [
|
|
1455
|
+
{
|
|
1456
|
+
name: "parachute-notes",
|
|
1457
|
+
port: 1942,
|
|
1458
|
+
paths: ["/notes/"],
|
|
1459
|
+
health: "/notes/health",
|
|
1460
|
+
version: "0.1.0",
|
|
1461
|
+
},
|
|
1462
|
+
];
|
|
1463
|
+
const exact = findServiceUpstream(services, "/notes");
|
|
1464
|
+
expect(exact?.port).toBe(1942);
|
|
1465
|
+
// mount is reported normalized (trailing slash stripped) so callers
|
|
1466
|
+
// computing `pathname.slice(match.mount.length)` (the stripPrefix path)
|
|
1467
|
+
// get the same answer regardless of how the entry was written on disk.
|
|
1468
|
+
expect(exact?.mount).toBe("/notes");
|
|
1469
|
+
|
|
1470
|
+
const asset = findServiceUpstream(services, "/notes/assets/index-XXX.js");
|
|
1471
|
+
expect(asset?.port).toBe(1942);
|
|
1472
|
+
expect(asset?.mount).toBe("/notes");
|
|
1473
|
+
expect(asset?.entry.name).toBe("parachute-notes");
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test('mount path "/" survives normalization without collapsing to empty string (#197)', () => {
|
|
1477
|
+
// Edge case: `"/".replace(/\/+$/, "")` yields the empty string; the
|
|
1478
|
+
// `|| "/"` branch keeps it stable so an exact-`/` request still matches.
|
|
1479
|
+
// Pre-fix this branch wasn't reachable (legacy `paths: ["/"]` entries
|
|
1480
|
+
// are already remapped to `/<shortname>` in-memory by services-manifest;
|
|
1481
|
+
// the test pins the lookup-level behavior so a future regression in the
|
|
1482
|
+
// remap layer doesn't silently 404 every catchall request).
|
|
1483
|
+
//
|
|
1484
|
+
// Sub-path matching for `/`-mounted entries is intentionally not asserted
|
|
1485
|
+
// here — that would change the existing "exact match only" behavior
|
|
1486
|
+
// captured in `pathname === path || pathname.startsWith(path + '/')`,
|
|
1487
|
+
// which never matched `/anything` when `path === "/"` (since `"//"` is
|
|
1488
|
+
// not a real URL prefix).
|
|
1489
|
+
const services: ServiceEntry[] = [
|
|
1490
|
+
{
|
|
1491
|
+
name: "catchall",
|
|
1492
|
+
port: 1950,
|
|
1493
|
+
paths: ["/"],
|
|
1494
|
+
health: "/health",
|
|
1495
|
+
version: "0.1.0",
|
|
1496
|
+
},
|
|
1497
|
+
];
|
|
1498
|
+
expect(findServiceUpstream(services, "/")?.port).toBe(1950);
|
|
1499
|
+
expect(findServiceUpstream(services, "/")?.mount).toBe("/");
|
|
1500
|
+
});
|
|
1147
1501
|
});
|
|
1148
1502
|
|
|
1149
1503
|
describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
@@ -1628,6 +1982,572 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
1628
1982
|
h.cleanup();
|
|
1629
1983
|
}
|
|
1630
1984
|
});
|
|
1985
|
+
|
|
1986
|
+
test("trailing-slash entry routes sub-paths end-to-end (#197)", async () => {
|
|
1987
|
+
// Operator-symptom regression: notes blank-screen on Aaron's box
|
|
1988
|
+
// (2026-05-08). services.json had `paths: ["/notes/"]` (trailing slash),
|
|
1989
|
+
// which used to make the matcher return undefined for every sub-path
|
|
1990
|
+
// because `pathname.startsWith("/notes//")` is always false. Hub
|
|
1991
|
+
// returned 404 for `/notes/assets/*.js` even though the SPA shell
|
|
1992
|
+
// loaded fine, breaking the page silently.
|
|
1993
|
+
const h = makeHarness();
|
|
1994
|
+
const upstream = startUpstream("notes");
|
|
1995
|
+
try {
|
|
1996
|
+
writeManifest(
|
|
1997
|
+
{
|
|
1998
|
+
services: [
|
|
1999
|
+
{
|
|
2000
|
+
name: "parachute-notes",
|
|
2001
|
+
port: upstream.port,
|
|
2002
|
+
paths: ["/notes/"],
|
|
2003
|
+
health: "/notes/health",
|
|
2004
|
+
version: "0.1.0",
|
|
2005
|
+
},
|
|
2006
|
+
],
|
|
2007
|
+
},
|
|
2008
|
+
h.manifestPath,
|
|
2009
|
+
);
|
|
2010
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2011
|
+
const res = await fetcher(req("/notes/assets/index-XXX.js"));
|
|
2012
|
+
expect(res.status).toBe(200);
|
|
2013
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
2014
|
+
expect(body.tag).toBe("notes");
|
|
2015
|
+
// Path is forwarded verbatim — no stripPrefix on the notes entry, so
|
|
2016
|
+
// backend sees the full mount-prefixed path.
|
|
2017
|
+
expect(body.pathname).toBe("/notes/assets/index-XXX.js");
|
|
2018
|
+
} finally {
|
|
2019
|
+
upstream.stop();
|
|
2020
|
+
h.cleanup();
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
test("FIRST_PARTY_FALLBACKS supplies stripPrefix when entry omits it (#196)", async () => {
|
|
2025
|
+
// Operator-symptom regression: scribe `/scribe/health` 404 on Aaron's
|
|
2026
|
+
// box (2026-05-08). Scribe v0.4.0 doesn't write `stripPrefix: true` to
|
|
2027
|
+
// its services.json entry; the declaration only lives in hub's
|
|
2028
|
+
// SCRIBE_FALLBACK manifest. Pre-#187 this didn't matter because the
|
|
2029
|
+
// per-service `tailscale serve` plan baked the path into the target
|
|
2030
|
+
// URL; post-#187 routing went through hub which wasn't consulting the
|
|
2031
|
+
// fallback registry. Result: hub forwarded `/scribe/health` verbatim
|
|
2032
|
+
// to scribe at :1943, scribe served bare paths and 404'd. Fix: hub-
|
|
2033
|
+
// side fallback merge in `stripPrefixFor`.
|
|
2034
|
+
//
|
|
2035
|
+
// Use a `parachute-scribe` manifestName so `shortNameForManifest`
|
|
2036
|
+
// resolves to "scribe" → SCRIBE_FALLBACK (which declares
|
|
2037
|
+
// `stripPrefix: true`). The entry itself omits stripPrefix to mirror
|
|
2038
|
+
// what scribe v0.4.0 actually writes today.
|
|
2039
|
+
const h = makeHarness();
|
|
2040
|
+
const upstream = startUpstream("scribe");
|
|
2041
|
+
try {
|
|
2042
|
+
writeManifest(
|
|
2043
|
+
{
|
|
2044
|
+
services: [
|
|
2045
|
+
{
|
|
2046
|
+
name: "parachute-scribe",
|
|
2047
|
+
port: upstream.port,
|
|
2048
|
+
paths: ["/scribe"],
|
|
2049
|
+
health: "/scribe/health",
|
|
2050
|
+
version: "0.4.0",
|
|
2051
|
+
// stripPrefix intentionally omitted — must be derived from
|
|
2052
|
+
// FIRST_PARTY_FALLBACKS.scribe.manifest.stripPrefix.
|
|
2053
|
+
},
|
|
2054
|
+
],
|
|
2055
|
+
},
|
|
2056
|
+
h.manifestPath,
|
|
2057
|
+
);
|
|
2058
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2059
|
+
const res = await fetcher(req("/scribe/health"));
|
|
2060
|
+
expect(res.status).toBe(200);
|
|
2061
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
2062
|
+
expect(body.tag).toBe("scribe");
|
|
2063
|
+
// The mount prefix is stripped — backend sees the bare `/health`
|
|
2064
|
+
// route that scribe v0.4.0 actually serves.
|
|
2065
|
+
expect(body.pathname).toBe("/health");
|
|
2066
|
+
} finally {
|
|
2067
|
+
upstream.stop();
|
|
2068
|
+
h.cleanup();
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
test("explicit stripPrefix:false on entry overrides FIRST_PARTY_FALLBACKS (#196)", async () => {
|
|
2073
|
+
// Explicit-on-entry must win, even when the fallback would default to
|
|
2074
|
+
// stripping. Documents the precedence ordering: explicit > fallback >
|
|
2075
|
+
// false. Without this, an operator who deliberately writes
|
|
2076
|
+
// `"stripPrefix": false` couldn't opt out of the fallback's strip.
|
|
2077
|
+
const h = makeHarness();
|
|
2078
|
+
const upstream = startUpstream("scribe");
|
|
2079
|
+
try {
|
|
2080
|
+
writeManifest(
|
|
2081
|
+
{
|
|
2082
|
+
services: [
|
|
2083
|
+
{
|
|
2084
|
+
name: "parachute-scribe",
|
|
2085
|
+
port: upstream.port,
|
|
2086
|
+
paths: ["/scribe"],
|
|
2087
|
+
health: "/scribe/health",
|
|
2088
|
+
version: "0.4.0",
|
|
2089
|
+
stripPrefix: false,
|
|
2090
|
+
},
|
|
2091
|
+
],
|
|
2092
|
+
},
|
|
2093
|
+
h.manifestPath,
|
|
2094
|
+
);
|
|
2095
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2096
|
+
const res = await fetcher(req("/scribe/health"));
|
|
2097
|
+
expect(res.status).toBe(200);
|
|
2098
|
+
const body = (await res.json()) as { pathname: string };
|
|
2099
|
+
// Explicit false wins — full path forwarded.
|
|
2100
|
+
expect(body.pathname).toBe("/scribe/health");
|
|
2101
|
+
} finally {
|
|
2102
|
+
upstream.stop();
|
|
2103
|
+
h.cleanup();
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
test("third-party service without fallback does not strip (#196)", async () => {
|
|
2108
|
+
// Default behavior contract: a service whose manifestName isn't in
|
|
2109
|
+
// FIRST_PARTY_FALLBACKS and whose entry omits stripPrefix gets the
|
|
2110
|
+
// pre-#196 keep-prefix behavior. No accidental strip on third-party
|
|
2111
|
+
// installs.
|
|
2112
|
+
const h = makeHarness();
|
|
2113
|
+
const upstream = startUpstream("third-party");
|
|
2114
|
+
try {
|
|
2115
|
+
writeManifest(
|
|
2116
|
+
{
|
|
2117
|
+
services: [
|
|
2118
|
+
{
|
|
2119
|
+
name: "third-party-service",
|
|
2120
|
+
port: upstream.port,
|
|
2121
|
+
paths: ["/third"],
|
|
2122
|
+
health: "/third/health",
|
|
2123
|
+
version: "0.1.0",
|
|
2124
|
+
},
|
|
2125
|
+
],
|
|
2126
|
+
},
|
|
2127
|
+
h.manifestPath,
|
|
2128
|
+
);
|
|
2129
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2130
|
+
const res = await fetcher(req("/third/health"));
|
|
2131
|
+
expect(res.status).toBe(200);
|
|
2132
|
+
const body = (await res.json()) as { pathname: string };
|
|
2133
|
+
expect(body.pathname).toBe("/third/health");
|
|
2134
|
+
} finally {
|
|
2135
|
+
upstream.stop();
|
|
2136
|
+
h.cleanup();
|
|
2137
|
+
}
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
describe("layerOf — classify trust layer from proxy headers", () => {
|
|
2142
|
+
// Hub binds 127.0.0.1:1939; only trusted forwarders (cloudflared,
|
|
2143
|
+
// tailscaled-serve, tailscaled-funnel) reach the listener. Spoofing isn't
|
|
2144
|
+
// a concern. layerOf inspects the headers each forwarder injects.
|
|
2145
|
+
|
|
2146
|
+
test("no proxy headers → loopback (direct localhost call)", () => {
|
|
2147
|
+
expect(layerOf(req("/"))).toBe("loopback");
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
|
|
2151
|
+
// Set verbatim per Tailscale docs / serve.go addTailscaleIdentityHeaders.
|
|
2152
|
+
const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
|
|
2153
|
+
expect(layerOf(r)).toBe("tailnet");
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
test("Tailscale-Funnel-Request: ?1 → public (Tailscale Funnel)", () => {
|
|
2157
|
+
// Tailscale Funnel sets this header on every funneled connection per
|
|
2158
|
+
// serve.go; mutually exclusive with Tailscale-User-Login.
|
|
2159
|
+
const r = req("/", { headers: { "Tailscale-Funnel-Request": "?1" } });
|
|
2160
|
+
expect(layerOf(r)).toBe("public");
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
test("CF-Ray → public (Cloudflare tunnel)", () => {
|
|
2164
|
+
const r = req("/", { headers: { "CF-Ray": "abc123-DEN" } });
|
|
2165
|
+
expect(layerOf(r)).toBe("public");
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
test("CF-Connecting-IP → public (Cloudflare tunnel — alt header shape)", () => {
|
|
2169
|
+
const r = req("/", { headers: { "CF-Connecting-IP": "203.0.113.42" } });
|
|
2170
|
+
expect(layerOf(r)).toBe("public");
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
test("Cloudflare wins over tailscale headers (cloudflared-then-serve hop, defensive)", () => {
|
|
2174
|
+
// If a node ran both forwarders chained, the outer-most public layer
|
|
2175
|
+
// wins. Defensive — not a recommended deployment shape.
|
|
2176
|
+
const r = req("/", {
|
|
2177
|
+
headers: { "CF-Ray": "abc", "Tailscale-User-Login": "alice@example.com" },
|
|
2178
|
+
});
|
|
2179
|
+
expect(layerOf(r)).toBe("public");
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
test("Tailscale-Funnel-Request wins over Tailscale-User-Login (defensive)", () => {
|
|
2183
|
+
// serve.go can't actually set both — funnel returns early. Defensive.
|
|
2184
|
+
const r = req("/", {
|
|
2185
|
+
headers: {
|
|
2186
|
+
"Tailscale-Funnel-Request": "?1",
|
|
2187
|
+
"Tailscale-User-Login": "alice@example.com",
|
|
2188
|
+
},
|
|
2189
|
+
});
|
|
2190
|
+
expect(layerOf(r)).toBe("public");
|
|
2191
|
+
});
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
2195
|
+
// The hub's only layer-gate. effectivePublicExposure(entry) === "loopback"
|
|
2196
|
+
// → 404 on tailnet/public; pass through on loopback. "allowed" /
|
|
2197
|
+
// "auth-required" reach all layers (service does its own auth gate).
|
|
2198
|
+
|
|
2199
|
+
function startUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
2200
|
+
const server = Bun.serve({
|
|
2201
|
+
port: 0,
|
|
2202
|
+
hostname: "127.0.0.1",
|
|
2203
|
+
fetch: () =>
|
|
2204
|
+
new Response(JSON.stringify({ tag: replyTag }), {
|
|
2205
|
+
status: 200,
|
|
2206
|
+
headers: { "content-type": "application/json" },
|
|
2207
|
+
}),
|
|
2208
|
+
});
|
|
2209
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
|
|
2213
|
+
const h = makeHarness();
|
|
2214
|
+
const upstream = startUpstream("loopback-only");
|
|
2215
|
+
try {
|
|
2216
|
+
writeManifest(
|
|
2217
|
+
{
|
|
2218
|
+
services: [
|
|
2219
|
+
{
|
|
2220
|
+
name: "loopback-only",
|
|
2221
|
+
port: upstream.port,
|
|
2222
|
+
paths: ["/loopback-only"],
|
|
2223
|
+
health: "/loopback-only/health",
|
|
2224
|
+
version: "0.1.0",
|
|
2225
|
+
publicExposure: "loopback",
|
|
2226
|
+
},
|
|
2227
|
+
],
|
|
2228
|
+
},
|
|
2229
|
+
h.manifestPath,
|
|
2230
|
+
);
|
|
2231
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2232
|
+
const r = req("/loopback-only/anything", {
|
|
2233
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2234
|
+
});
|
|
2235
|
+
const res = await fetcher(r);
|
|
2236
|
+
expect(res.status).toBe(404);
|
|
2237
|
+
} finally {
|
|
2238
|
+
upstream.stop();
|
|
2239
|
+
h.cleanup();
|
|
2240
|
+
}
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("publicExposure: loopback + public header → 404 (gate hides the route)", async () => {
|
|
2244
|
+
const h = makeHarness();
|
|
2245
|
+
const upstream = startUpstream("loopback-only");
|
|
2246
|
+
try {
|
|
2247
|
+
writeManifest(
|
|
2248
|
+
{
|
|
2249
|
+
services: [
|
|
2250
|
+
{
|
|
2251
|
+
name: "loopback-only",
|
|
2252
|
+
port: upstream.port,
|
|
2253
|
+
paths: ["/loopback-only"],
|
|
2254
|
+
health: "/loopback-only/health",
|
|
2255
|
+
version: "0.1.0",
|
|
2256
|
+
publicExposure: "loopback",
|
|
2257
|
+
},
|
|
2258
|
+
],
|
|
2259
|
+
},
|
|
2260
|
+
h.manifestPath,
|
|
2261
|
+
);
|
|
2262
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2263
|
+
const r = req("/loopback-only/anything", { headers: { "CF-Ray": "abc123" } });
|
|
2264
|
+
const res = await fetcher(r);
|
|
2265
|
+
expect(res.status).toBe(404);
|
|
2266
|
+
} finally {
|
|
2267
|
+
upstream.stop();
|
|
2268
|
+
h.cleanup();
|
|
2269
|
+
}
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
|
|
2273
|
+
const h = makeHarness();
|
|
2274
|
+
const upstream = startUpstream("loopback-only");
|
|
2275
|
+
try {
|
|
2276
|
+
writeManifest(
|
|
2277
|
+
{
|
|
2278
|
+
services: [
|
|
2279
|
+
{
|
|
2280
|
+
name: "loopback-only",
|
|
2281
|
+
port: upstream.port,
|
|
2282
|
+
paths: ["/loopback-only"],
|
|
2283
|
+
health: "/loopback-only/health",
|
|
2284
|
+
version: "0.1.0",
|
|
2285
|
+
publicExposure: "loopback",
|
|
2286
|
+
},
|
|
2287
|
+
],
|
|
2288
|
+
},
|
|
2289
|
+
h.manifestPath,
|
|
2290
|
+
);
|
|
2291
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2292
|
+
const res = await fetcher(req("/loopback-only/health"));
|
|
2293
|
+
expect(res.status).toBe(200);
|
|
2294
|
+
const body = (await res.json()) as { tag: string };
|
|
2295
|
+
expect(body.tag).toBe("loopback-only");
|
|
2296
|
+
} finally {
|
|
2297
|
+
upstream.stop();
|
|
2298
|
+
h.cleanup();
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
2303
|
+
const h = makeHarness();
|
|
2304
|
+
const upstream = startUpstream("allowed");
|
|
2305
|
+
try {
|
|
2306
|
+
writeManifest(
|
|
2307
|
+
{
|
|
2308
|
+
services: [
|
|
2309
|
+
{
|
|
2310
|
+
name: "allowed",
|
|
2311
|
+
port: upstream.port,
|
|
2312
|
+
paths: ["/allowed"],
|
|
2313
|
+
health: "/allowed/health",
|
|
2314
|
+
version: "0.1.0",
|
|
2315
|
+
publicExposure: "allowed",
|
|
2316
|
+
},
|
|
2317
|
+
],
|
|
2318
|
+
},
|
|
2319
|
+
h.manifestPath,
|
|
2320
|
+
);
|
|
2321
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2322
|
+
const r = req("/allowed/health", {
|
|
2323
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2324
|
+
});
|
|
2325
|
+
const res = await fetcher(r);
|
|
2326
|
+
expect(res.status).toBe(200);
|
|
2327
|
+
const body = (await res.json()) as { tag: string };
|
|
2328
|
+
expect(body.tag).toBe("allowed");
|
|
2329
|
+
} finally {
|
|
2330
|
+
upstream.stop();
|
|
2331
|
+
h.cleanup();
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
test("publicExposure: auth-required + public header → reaches upstream (service self-gates)", async () => {
|
|
2336
|
+
// The service does its own auth check; the hub passes through.
|
|
2337
|
+
const h = makeHarness();
|
|
2338
|
+
const upstream = startUpstream("auth-required");
|
|
2339
|
+
try {
|
|
2340
|
+
writeManifest(
|
|
2341
|
+
{
|
|
2342
|
+
services: [
|
|
2343
|
+
{
|
|
2344
|
+
name: "auth-required",
|
|
2345
|
+
port: upstream.port,
|
|
2346
|
+
paths: ["/auth-required"],
|
|
2347
|
+
health: "/auth-required/health",
|
|
2348
|
+
version: "0.1.0",
|
|
2349
|
+
publicExposure: "auth-required",
|
|
2350
|
+
},
|
|
2351
|
+
],
|
|
2352
|
+
},
|
|
2353
|
+
h.manifestPath,
|
|
2354
|
+
);
|
|
2355
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2356
|
+
const r = req("/auth-required/health", { headers: { "CF-Ray": "abc123" } });
|
|
2357
|
+
const res = await fetcher(r);
|
|
2358
|
+
expect(res.status).toBe(200);
|
|
2359
|
+
} finally {
|
|
2360
|
+
upstream.stop();
|
|
2361
|
+
h.cleanup();
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
test("scribe (kind=api, hasAuth=false default) → loopback gate fires from public layer", async () => {
|
|
2366
|
+
// Spec-derived default for scribe is "auth-required" (NOT loopback —
|
|
2367
|
+
// see effectivePublicExposure in service-spec.ts). So the hub passes
|
|
2368
|
+
// through; this test confirms the spec-default isn't accidentally
|
|
2369
|
+
// loopback-gating well-known services.
|
|
2370
|
+
const h = makeHarness();
|
|
2371
|
+
const upstream = startUpstream("scribe");
|
|
2372
|
+
try {
|
|
2373
|
+
writeManifest(
|
|
2374
|
+
{
|
|
2375
|
+
services: [
|
|
2376
|
+
{
|
|
2377
|
+
name: "parachute-scribe",
|
|
2378
|
+
port: upstream.port,
|
|
2379
|
+
paths: ["/scribe"],
|
|
2380
|
+
health: "/scribe/health",
|
|
2381
|
+
version: "0.1.0",
|
|
2382
|
+
// publicExposure absent — exercises spec-derived default
|
|
2383
|
+
},
|
|
2384
|
+
],
|
|
2385
|
+
},
|
|
2386
|
+
h.manifestPath,
|
|
2387
|
+
);
|
|
2388
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2389
|
+
const r = req("/scribe/health", { headers: { "CF-Ray": "abc123" } });
|
|
2390
|
+
const res = await fetcher(r);
|
|
2391
|
+
// auth-required → pass through; service does its own gate.
|
|
2392
|
+
expect(res.status).toBe(200);
|
|
2393
|
+
} finally {
|
|
2394
|
+
upstream.stop();
|
|
2395
|
+
h.cleanup();
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
test("unknown third-party service (no SERVICE_SPECS row, no publicExposure) → defaults to allowed, reaches public layer", async () => {
|
|
2400
|
+
// Third-party modules installed via `module.json` aren't in
|
|
2401
|
+
// FIRST_PARTY_FALLBACKS, so effectivePublicExposure has no spec to
|
|
2402
|
+
// derive from. The contract documented on effectivePublicExposure is
|
|
2403
|
+
// "default to 'allowed'", which means the gate must NOT fire from the
|
|
2404
|
+
// public layer for an unknown service that didn't opt into a stricter
|
|
2405
|
+
// exposure. Regression-guards anyone tightening the default to
|
|
2406
|
+
// "loopback" without realizing it would silently 404 every
|
|
2407
|
+
// third-party module on tailnet/public.
|
|
2408
|
+
const h = makeHarness();
|
|
2409
|
+
const upstream = startUpstream("unknown-thirdparty");
|
|
2410
|
+
try {
|
|
2411
|
+
writeManifest(
|
|
2412
|
+
{
|
|
2413
|
+
services: [
|
|
2414
|
+
{
|
|
2415
|
+
name: "parachute-unknown-thirdparty",
|
|
2416
|
+
port: upstream.port,
|
|
2417
|
+
paths: ["/parachute-unknown-thirdparty"],
|
|
2418
|
+
health: "/parachute-unknown-thirdparty/health",
|
|
2419
|
+
version: "0.1.0",
|
|
2420
|
+
// publicExposure absent — exercises the unknown-spec default path
|
|
2421
|
+
// kind absent — no SERVICE_SPECS / FIRST_PARTY_FALLBACKS row matches
|
|
2422
|
+
},
|
|
2423
|
+
],
|
|
2424
|
+
},
|
|
2425
|
+
h.manifestPath,
|
|
2426
|
+
);
|
|
2427
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2428
|
+
const r = req("/parachute-unknown-thirdparty/health", {
|
|
2429
|
+
headers: { "CF-Ray": "abc123" },
|
|
2430
|
+
});
|
|
2431
|
+
const res = await fetcher(r);
|
|
2432
|
+
// Default "allowed" → no gate. Forwarded to upstream.
|
|
2433
|
+
expect(res.status).toBe(200);
|
|
2434
|
+
const body = (await res.json()) as { tag: string };
|
|
2435
|
+
expect(body.tag).toBe("unknown-thirdparty");
|
|
2436
|
+
} finally {
|
|
2437
|
+
upstream.stop();
|
|
2438
|
+
h.cleanup();
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
2444
|
+
// Same gate, applied to /vault/<name>/* dispatch. A vault entry that
|
|
2445
|
+
// declares publicExposure: "loopback" is hidden from non-loopback callers.
|
|
2446
|
+
|
|
2447
|
+
function startVaultUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
2448
|
+
const server = Bun.serve({
|
|
2449
|
+
port: 0,
|
|
2450
|
+
hostname: "127.0.0.1",
|
|
2451
|
+
fetch: () =>
|
|
2452
|
+
new Response(JSON.stringify({ tag: replyTag }), {
|
|
2453
|
+
status: 200,
|
|
2454
|
+
headers: { "content-type": "application/json" },
|
|
2455
|
+
}),
|
|
2456
|
+
});
|
|
2457
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
test("vault publicExposure: loopback + tailnet header → 404", async () => {
|
|
2461
|
+
const h = makeHarness();
|
|
2462
|
+
const upstream = startVaultUpstream("vault-private");
|
|
2463
|
+
try {
|
|
2464
|
+
writeManifest(
|
|
2465
|
+
{
|
|
2466
|
+
services: [
|
|
2467
|
+
{
|
|
2468
|
+
name: "parachute-vault-private",
|
|
2469
|
+
port: upstream.port,
|
|
2470
|
+
paths: ["/vault/private"],
|
|
2471
|
+
health: "/vault/private/health",
|
|
2472
|
+
version: "0.4.0",
|
|
2473
|
+
publicExposure: "loopback",
|
|
2474
|
+
},
|
|
2475
|
+
],
|
|
2476
|
+
},
|
|
2477
|
+
h.manifestPath,
|
|
2478
|
+
);
|
|
2479
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2480
|
+
const r = req("/vault/private/health", {
|
|
2481
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2482
|
+
});
|
|
2483
|
+
const res = await fetcher(r);
|
|
2484
|
+
expect(res.status).toBe(404);
|
|
2485
|
+
} finally {
|
|
2486
|
+
upstream.stop();
|
|
2487
|
+
h.cleanup();
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
|
|
2492
|
+
const h = makeHarness();
|
|
2493
|
+
const upstream = startVaultUpstream("vault-private");
|
|
2494
|
+
try {
|
|
2495
|
+
writeManifest(
|
|
2496
|
+
{
|
|
2497
|
+
services: [
|
|
2498
|
+
{
|
|
2499
|
+
name: "parachute-vault-private",
|
|
2500
|
+
port: upstream.port,
|
|
2501
|
+
paths: ["/vault/private"],
|
|
2502
|
+
health: "/vault/private/health",
|
|
2503
|
+
version: "0.4.0",
|
|
2504
|
+
publicExposure: "loopback",
|
|
2505
|
+
},
|
|
2506
|
+
],
|
|
2507
|
+
},
|
|
2508
|
+
h.manifestPath,
|
|
2509
|
+
);
|
|
2510
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2511
|
+
const res = await fetcher(req("/vault/private/health"));
|
|
2512
|
+
expect(res.status).toBe(200);
|
|
2513
|
+
const body = (await res.json()) as { tag: string };
|
|
2514
|
+
expect(body.tag).toBe("vault-private");
|
|
2515
|
+
} finally {
|
|
2516
|
+
upstream.stop();
|
|
2517
|
+
h.cleanup();
|
|
2518
|
+
}
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
|
|
2522
|
+
const h = makeHarness();
|
|
2523
|
+
const upstream = startVaultUpstream("vault-public");
|
|
2524
|
+
try {
|
|
2525
|
+
writeManifest(
|
|
2526
|
+
{
|
|
2527
|
+
services: [
|
|
2528
|
+
{
|
|
2529
|
+
name: "parachute-vault",
|
|
2530
|
+
port: upstream.port,
|
|
2531
|
+
paths: ["/vault/default"],
|
|
2532
|
+
health: "/vault/default/health",
|
|
2533
|
+
version: "0.4.0",
|
|
2534
|
+
publicExposure: "allowed",
|
|
2535
|
+
},
|
|
2536
|
+
],
|
|
2537
|
+
},
|
|
2538
|
+
h.manifestPath,
|
|
2539
|
+
);
|
|
2540
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2541
|
+
const r = req("/vault/default/health", {
|
|
2542
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2543
|
+
});
|
|
2544
|
+
const res = await fetcher(r);
|
|
2545
|
+
expect(res.status).toBe(200);
|
|
2546
|
+
} finally {
|
|
2547
|
+
upstream.stop();
|
|
2548
|
+
h.cleanup();
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
1631
2551
|
});
|
|
1632
2552
|
|
|
1633
2553
|
/** Find a port that no one is listening on by binding briefly and releasing. */
|