@openparachute/hub 0.5.7 → 0.5.10-rc.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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- 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 +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- 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/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- 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/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -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
|
@@ -9,6 +9,7 @@ import { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hu
|
|
|
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";
|
|
12
|
+
import { createUser } from "../users.ts";
|
|
12
13
|
|
|
13
14
|
interface Harness {
|
|
14
15
|
dir: string;
|
|
@@ -57,7 +58,7 @@ describe("hubFetch routing", () => {
|
|
|
57
58
|
}
|
|
58
59
|
});
|
|
59
60
|
|
|
60
|
-
test("/hub.html serves the same file as /", async () => {
|
|
61
|
+
test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
|
|
61
62
|
const h = makeHarness();
|
|
62
63
|
try {
|
|
63
64
|
writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
|
|
@@ -69,6 +70,58 @@ describe("hubFetch routing", () => {
|
|
|
69
70
|
}
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
74
|
+
// The dynamic path takes over from the static disk file the moment a
|
|
75
|
+
// DB is configured. With no session cookie, we still render — just
|
|
76
|
+
// with the "Sign in" affordance.
|
|
77
|
+
const h = makeHarness();
|
|
78
|
+
try {
|
|
79
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
80
|
+
try {
|
|
81
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
84
|
+
const body = await res.text();
|
|
85
|
+
expect(body).toContain('class="auth-indicator"');
|
|
86
|
+
expect(body).toContain("Sign in");
|
|
87
|
+
expect(body).not.toContain("Signed in as");
|
|
88
|
+
} finally {
|
|
89
|
+
db.close();
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
h.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
97
|
+
const h = makeHarness();
|
|
98
|
+
try {
|
|
99
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
100
|
+
try {
|
|
101
|
+
const { createUser } = await import("../users.ts");
|
|
102
|
+
const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
|
|
103
|
+
"../sessions.ts"
|
|
104
|
+
);
|
|
105
|
+
const user = await createUser(db, "aaron", "pw");
|
|
106
|
+
const session = createSession(db, { userId: user.id });
|
|
107
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
108
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
const body = await res.text();
|
|
111
|
+
expect(body).toContain("Signed in as");
|
|
112
|
+
expect(body).toContain("aaron");
|
|
113
|
+
expect(body).toContain('action="/logout"');
|
|
114
|
+
expect(body).toContain('name="__csrf"');
|
|
115
|
+
// CSRF cookie was minted on the response (no prior cookie present).
|
|
116
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
|
|
117
|
+
} finally {
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
h.cleanup();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
72
125
|
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
73
126
|
const h = makeHarness();
|
|
74
127
|
try {
|
|
@@ -194,6 +247,105 @@ describe("hubFetch routing", () => {
|
|
|
194
247
|
}
|
|
195
248
|
});
|
|
196
249
|
|
|
250
|
+
// Phase D consumer-side: each non-vault service entry's
|
|
251
|
+
// module.json:uiUrl + displayName ride through to doc.services. The
|
|
252
|
+
// discovery page (`/`) reads them to render data-driven Service tiles.
|
|
253
|
+
test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
|
|
254
|
+
const h = makeHarness();
|
|
255
|
+
try {
|
|
256
|
+
const notesEntry: ServiceEntry = {
|
|
257
|
+
name: "parachute-notes",
|
|
258
|
+
port: 5173,
|
|
259
|
+
paths: ["/notes"],
|
|
260
|
+
health: "/health",
|
|
261
|
+
version: "0.0.1",
|
|
262
|
+
installDir: "/fake/notes",
|
|
263
|
+
};
|
|
264
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
265
|
+
const res = await hubFetch(h.dir, {
|
|
266
|
+
manifestPath: h.manifestPath,
|
|
267
|
+
readModuleManifest: async () => ({
|
|
268
|
+
name: "notes",
|
|
269
|
+
manifestName: "parachute-notes",
|
|
270
|
+
kind: "frontend",
|
|
271
|
+
port: 5173,
|
|
272
|
+
paths: ["/notes"],
|
|
273
|
+
health: "/health",
|
|
274
|
+
uiUrl: "/notes",
|
|
275
|
+
displayName: "Notes",
|
|
276
|
+
}),
|
|
277
|
+
})(req("/.well-known/parachute.json"));
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
const body = (await res.json()) as {
|
|
280
|
+
services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
|
|
281
|
+
};
|
|
282
|
+
const svc = body.services.find((s) => s.name === "parachute-notes");
|
|
283
|
+
expect(svc?.uiUrl).toMatch(/\/notes$/);
|
|
284
|
+
expect(svc?.displayName).toBe("Notes");
|
|
285
|
+
} finally {
|
|
286
|
+
h.cleanup();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
|
|
291
|
+
const h = makeHarness();
|
|
292
|
+
try {
|
|
293
|
+
const notesEntry: ServiceEntry = {
|
|
294
|
+
name: "parachute-notes",
|
|
295
|
+
port: 5173,
|
|
296
|
+
paths: ["/notes"],
|
|
297
|
+
health: "/health",
|
|
298
|
+
version: "0.0.1",
|
|
299
|
+
installDir: "/fake/notes",
|
|
300
|
+
};
|
|
301
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
302
|
+
const res = await hubFetch(h.dir, {
|
|
303
|
+
manifestPath: h.manifestPath,
|
|
304
|
+
readModuleManifest: async () => ({
|
|
305
|
+
name: "notes",
|
|
306
|
+
manifestName: "parachute-notes",
|
|
307
|
+
kind: "frontend",
|
|
308
|
+
port: 5173,
|
|
309
|
+
paths: ["/notes"],
|
|
310
|
+
health: "/health",
|
|
311
|
+
// no uiUrl declared — discovery page will skip the tile.
|
|
312
|
+
}),
|
|
313
|
+
})(req("/.well-known/parachute.json"));
|
|
314
|
+
const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
|
|
315
|
+
expect(body.services[0]).not.toHaveProperty("uiUrl");
|
|
316
|
+
} finally {
|
|
317
|
+
h.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
|
|
322
|
+
const h = makeHarness();
|
|
323
|
+
try {
|
|
324
|
+
const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
|
|
325
|
+
writeManifest({ services: [vaultWithDir] }, h.manifestPath);
|
|
326
|
+
// The fake module.json declares uiUrl, but vault is supposed to be
|
|
327
|
+
// skipped by loadServiceUiMetadata (it has its own managementUrl
|
|
328
|
+
// path). So doc.services[vault] should NOT carry uiUrl.
|
|
329
|
+
const res = await hubFetch(h.dir, {
|
|
330
|
+
manifestPath: h.manifestPath,
|
|
331
|
+
readModuleManifest: async () => ({
|
|
332
|
+
name: "vault",
|
|
333
|
+
manifestName: "parachute-vault",
|
|
334
|
+
kind: "api",
|
|
335
|
+
port: 1940,
|
|
336
|
+
paths: ["/vault/default"],
|
|
337
|
+
health: "/health",
|
|
338
|
+
uiUrl: "/should-be-ignored",
|
|
339
|
+
}),
|
|
340
|
+
})(req("/.well-known/parachute.json"));
|
|
341
|
+
const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
|
|
342
|
+
const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
|
|
343
|
+
expect(vaultSvc).not.toHaveProperty("uiUrl");
|
|
344
|
+
} finally {
|
|
345
|
+
h.cleanup();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
197
349
|
// The bug this PR fixes: `parachute vault create techne` updates
|
|
198
350
|
// services.json but the old code only re-derived parachute.json on
|
|
199
351
|
// `parachute expose`. With the dynamic build, the second GET reflects
|
|
@@ -398,15 +550,12 @@ describe("hubFetch routing", () => {
|
|
|
398
550
|
}
|
|
399
551
|
});
|
|
400
552
|
|
|
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.
|
|
553
|
+
// SPA mount after hub#231: single `/admin/*` mount serves vault
|
|
554
|
+
// provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
|
|
555
|
+
// SPA URLs are 301-redirected; the per-vault content proxy at
|
|
556
|
+
// `/vault/<name>/*` stays where it is.
|
|
408
557
|
|
|
409
|
-
test("/
|
|
558
|
+
test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
|
|
410
559
|
const h = makeHarness();
|
|
411
560
|
try {
|
|
412
561
|
const dist = join(h.dir, "dist");
|
|
@@ -414,7 +563,7 @@ describe("hubFetch routing", () => {
|
|
|
414
563
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
415
564
|
writeManifest({ services: [] }, h.manifestPath);
|
|
416
565
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
417
|
-
req("/
|
|
566
|
+
req("/admin/vaults"),
|
|
418
567
|
);
|
|
419
568
|
expect(res.status).toBe(200);
|
|
420
569
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -424,10 +573,7 @@ describe("hubFetch routing", () => {
|
|
|
424
573
|
}
|
|
425
574
|
});
|
|
426
575
|
|
|
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.
|
|
576
|
+
test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
|
|
431
577
|
const h = makeHarness();
|
|
432
578
|
try {
|
|
433
579
|
const dist = join(h.dir, "dist");
|
|
@@ -435,7 +581,7 @@ describe("hubFetch routing", () => {
|
|
|
435
581
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
436
582
|
writeManifest({ services: [] }, h.manifestPath);
|
|
437
583
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
438
|
-
req("/
|
|
584
|
+
req("/admin/vaults/new"),
|
|
439
585
|
);
|
|
440
586
|
expect(res.status).toBe(200);
|
|
441
587
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -445,7 +591,34 @@ describe("hubFetch routing", () => {
|
|
|
445
591
|
}
|
|
446
592
|
});
|
|
447
593
|
|
|
448
|
-
test("/
|
|
594
|
+
test("/admin/permissions serves the SPA shell", async () => {
|
|
595
|
+
const h = makeHarness();
|
|
596
|
+
try {
|
|
597
|
+
const dist = join(h.dir, "dist");
|
|
598
|
+
mkdirIfMissing(dist);
|
|
599
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
600
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
|
|
601
|
+
expect(res.status).toBe(200);
|
|
602
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
603
|
+
} finally {
|
|
604
|
+
h.cleanup();
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("/admin/tokens serves the SPA shell", async () => {
|
|
609
|
+
const h = makeHarness();
|
|
610
|
+
try {
|
|
611
|
+
const dist = join(h.dir, "dist");
|
|
612
|
+
mkdirIfMissing(dist);
|
|
613
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
614
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
|
|
615
|
+
expect(res.status).toBe(200);
|
|
616
|
+
} finally {
|
|
617
|
+
h.cleanup();
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("/admin/assets/*.js is served with the matching content-type", async () => {
|
|
449
622
|
const h = makeHarness();
|
|
450
623
|
try {
|
|
451
624
|
const dist = join(h.dir, "dist");
|
|
@@ -456,7 +629,7 @@ describe("hubFetch routing", () => {
|
|
|
456
629
|
writeFileSync(join(assets, "main.js"), "console.log('hi');");
|
|
457
630
|
writeManifest({ services: [] }, h.manifestPath);
|
|
458
631
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
459
|
-
req("/
|
|
632
|
+
req("/admin/assets/main.js"),
|
|
460
633
|
);
|
|
461
634
|
expect(res.status).toBe(200);
|
|
462
635
|
expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
|
|
@@ -466,14 +639,14 @@ describe("hubFetch routing", () => {
|
|
|
466
639
|
}
|
|
467
640
|
});
|
|
468
641
|
|
|
469
|
-
test("/
|
|
642
|
+
test("/admin/* returns 503 with build hint when dist is missing", async () => {
|
|
470
643
|
const h = makeHarness();
|
|
471
644
|
try {
|
|
472
645
|
writeManifest({ services: [] }, h.manifestPath);
|
|
473
646
|
const res = await hubFetch(h.dir, {
|
|
474
647
|
spaDistDir: join(h.dir, "missing"),
|
|
475
648
|
manifestPath: h.manifestPath,
|
|
476
|
-
})(req("/
|
|
649
|
+
})(req("/admin/vaults"));
|
|
477
650
|
expect(res.status).toBe(503);
|
|
478
651
|
expect(await res.text()).toContain("bun run build");
|
|
479
652
|
} finally {
|
|
@@ -481,125 +654,251 @@ describe("hubFetch routing", () => {
|
|
|
481
654
|
}
|
|
482
655
|
});
|
|
483
656
|
|
|
484
|
-
test("/
|
|
657
|
+
test("/admin/vaults rejects non-GET methods with 405", async () => {
|
|
485
658
|
const h = makeHarness();
|
|
486
659
|
try {
|
|
487
660
|
const dist = join(h.dir, "dist");
|
|
488
661
|
mkdirIfMissing(dist);
|
|
489
662
|
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
490
|
-
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
663
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
664
|
+
req("/admin/vaults", { method: "POST" }),
|
|
665
|
+
);
|
|
491
666
|
expect(res.status).toBe(405);
|
|
492
667
|
} finally {
|
|
493
668
|
h.cleanup();
|
|
494
669
|
}
|
|
495
670
|
});
|
|
496
671
|
|
|
497
|
-
|
|
672
|
+
// 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
|
|
673
|
+
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
674
|
+
// dispatch — operator bookmarks landing on any of these still work.
|
|
675
|
+
|
|
676
|
+
test("301: /vault → /admin/vaults", async () => {
|
|
498
677
|
const h = makeHarness();
|
|
499
678
|
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>");
|
|
679
|
+
const res = await hubFetch(h.dir)(req("/vault"));
|
|
680
|
+
expect(res.status).toBe(301);
|
|
681
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
507
682
|
} finally {
|
|
508
683
|
h.cleanup();
|
|
509
684
|
}
|
|
510
685
|
});
|
|
511
686
|
|
|
512
|
-
test("/
|
|
687
|
+
test("301: /vault/new → /admin/vaults/new", async () => {
|
|
513
688
|
const h = makeHarness();
|
|
514
689
|
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");
|
|
690
|
+
const res = await hubFetch(h.dir)(req("/vault/new"));
|
|
691
|
+
expect(res.status).toBe(301);
|
|
692
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
520
693
|
} finally {
|
|
521
694
|
h.cleanup();
|
|
522
695
|
}
|
|
523
696
|
});
|
|
524
697
|
|
|
525
|
-
test("/
|
|
698
|
+
test("301: /vault preserves query string", async () => {
|
|
526
699
|
const h = makeHarness();
|
|
527
700
|
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);
|
|
701
|
+
const res = await hubFetch(h.dir)(req("/vault?next=foo"));
|
|
702
|
+
expect(res.status).toBe(301);
|
|
703
|
+
expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
|
|
535
704
|
} finally {
|
|
536
705
|
h.cleanup();
|
|
537
706
|
}
|
|
538
707
|
});
|
|
539
708
|
|
|
540
|
-
test("/hub/vaults
|
|
541
|
-
//
|
|
542
|
-
//
|
|
709
|
+
test("301: /hub/vaults → /admin/vaults (chain through the rename)", async () => {
|
|
710
|
+
// The /hub/vaults redirect predates #231 — it used to land at /vault.
|
|
711
|
+
// Now it lands at the final /admin/vaults so old bookmarks don't bounce
|
|
712
|
+
// through two redirects.
|
|
543
713
|
const h = makeHarness();
|
|
544
714
|
try {
|
|
545
715
|
const res = await hubFetch(h.dir)(req("/hub/vaults"));
|
|
546
716
|
expect(res.status).toBe(301);
|
|
547
|
-
expect(res.headers.get("location")).toBe("/
|
|
717
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
548
718
|
} finally {
|
|
549
719
|
h.cleanup();
|
|
550
720
|
}
|
|
551
721
|
});
|
|
552
722
|
|
|
553
|
-
test("/hub/vaults/new
|
|
723
|
+
test("301: /hub/vaults/new → /admin/vaults/new", async () => {
|
|
554
724
|
const h = makeHarness();
|
|
555
725
|
try {
|
|
556
726
|
const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
|
|
557
727
|
expect(res.status).toBe(301);
|
|
558
|
-
expect(res.headers.get("location")).toBe("/
|
|
728
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
559
729
|
} finally {
|
|
560
730
|
h.cleanup();
|
|
561
731
|
}
|
|
562
732
|
});
|
|
563
733
|
|
|
564
|
-
test("/hub/vaults/* preserves the query string
|
|
734
|
+
test("301: /hub/vaults/* preserves the query string", async () => {
|
|
565
735
|
const h = makeHarness();
|
|
566
736
|
try {
|
|
567
737
|
const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
|
|
568
738
|
expect(res.status).toBe(301);
|
|
569
|
-
expect(res.headers.get("location")).toBe("/
|
|
739
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
|
|
740
|
+
} finally {
|
|
741
|
+
h.cleanup();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("301: /hub/permissions → /admin/permissions", async () => {
|
|
746
|
+
const h = makeHarness();
|
|
747
|
+
try {
|
|
748
|
+
const res = await hubFetch(h.dir)(req("/hub/permissions"));
|
|
749
|
+
expect(res.status).toBe(301);
|
|
750
|
+
expect(res.headers.get("location")).toBe("/admin/permissions");
|
|
751
|
+
} finally {
|
|
752
|
+
h.cleanup();
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("301: /hub/tokens → /admin/tokens", async () => {
|
|
757
|
+
const h = makeHarness();
|
|
758
|
+
try {
|
|
759
|
+
const res = await hubFetch(h.dir)(req("/hub/tokens"));
|
|
760
|
+
expect(res.status).toBe(301);
|
|
761
|
+
expect(res.headers.get("location")).toBe("/admin/tokens");
|
|
762
|
+
} finally {
|
|
763
|
+
h.cleanup();
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("301: /hub bare → /admin/vaults", async () => {
|
|
768
|
+
const h = makeHarness();
|
|
769
|
+
try {
|
|
770
|
+
const res = await hubFetch(h.dir)(req("/hub"));
|
|
771
|
+
expect(res.status).toBe(301);
|
|
772
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
773
|
+
} finally {
|
|
774
|
+
h.cleanup();
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Login surface rename redirects (auth-UX cleanup): /admin/login and
|
|
779
|
+
// /admin/logout 301 to /login and /logout. Path-only test — the
|
|
780
|
+
// handlers themselves are exercised through the existing
|
|
781
|
+
// handleAdminLoginGet/Post + handleAdminLogoutPost test files.
|
|
782
|
+
test("301: /admin/login → /login", async () => {
|
|
783
|
+
const h = makeHarness();
|
|
784
|
+
try {
|
|
785
|
+
const res = await hubFetch(h.dir)(req("/admin/login"));
|
|
786
|
+
expect(res.status).toBe(301);
|
|
787
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
788
|
+
} finally {
|
|
789
|
+
h.cleanup();
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("301: /admin/login preserves the next= query param", async () => {
|
|
794
|
+
const h = makeHarness();
|
|
795
|
+
try {
|
|
796
|
+
const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
|
|
797
|
+
expect(res.status).toBe(301);
|
|
798
|
+
expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
|
|
799
|
+
} finally {
|
|
800
|
+
h.cleanup();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
|
|
805
|
+
const h = makeHarness();
|
|
806
|
+
try {
|
|
807
|
+
const res = await hubFetch(h.dir)(req("/admin/config"));
|
|
808
|
+
expect(res.status).toBe(301);
|
|
809
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
810
|
+
} finally {
|
|
811
|
+
h.cleanup();
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("301: /admin/config/<name> → /admin/vaults", async () => {
|
|
816
|
+
const h = makeHarness();
|
|
817
|
+
try {
|
|
818
|
+
const res = await hubFetch(h.dir)(req("/admin/config/vault"));
|
|
819
|
+
expect(res.status).toBe(301);
|
|
820
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
570
821
|
} finally {
|
|
571
822
|
h.cleanup();
|
|
572
823
|
}
|
|
573
824
|
});
|
|
574
825
|
|
|
575
|
-
test("/
|
|
826
|
+
test("301: /admin/logout → /logout", async () => {
|
|
827
|
+
const h = makeHarness();
|
|
828
|
+
try {
|
|
829
|
+
const res = await hubFetch(h.dir)(req("/admin/logout"));
|
|
830
|
+
expect(res.status).toBe(301);
|
|
831
|
+
expect(res.headers.get("location")).toBe("/logout");
|
|
832
|
+
} finally {
|
|
833
|
+
h.cleanup();
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
|
|
838
|
+
const h = makeHarness();
|
|
839
|
+
try {
|
|
840
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
841
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
842
|
+
req("/hub/unknown-thing"),
|
|
843
|
+
);
|
|
844
|
+
expect(res.status).toBe(404);
|
|
845
|
+
} finally {
|
|
846
|
+
h.cleanup();
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("/oauth/authorize without configured db returns 503 JSON", async () => {
|
|
576
851
|
const h = makeHarness();
|
|
577
852
|
try {
|
|
578
853
|
const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
|
|
579
854
|
expect(res.status).toBe(503);
|
|
855
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
856
|
+
expect(body.error).toBe("service_unavailable");
|
|
857
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
580
858
|
} finally {
|
|
581
859
|
h.cleanup();
|
|
582
860
|
}
|
|
583
861
|
});
|
|
584
862
|
|
|
585
|
-
test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
|
|
863
|
+
test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
|
|
586
864
|
const h = makeHarness();
|
|
587
865
|
try {
|
|
588
866
|
const fetch = hubFetch(h.dir);
|
|
867
|
+
// Every DB-dependent guard returns the same JSON 503 shape
|
|
868
|
+
// (`service_unavailable`) so consumers don't branch on content-type to
|
|
869
|
+
// extract the message. The pattern was already canonical on
|
|
870
|
+
// /api/auth/* (hub#215, #226) and was extended to all guards in
|
|
871
|
+
// hub#227.
|
|
589
872
|
const cases: Array<[string, RequestInit]> = [
|
|
873
|
+
["/oauth/authorize?client_id=x", { method: "GET" }],
|
|
874
|
+
["/oauth/authorize/approve", { method: "POST" }],
|
|
590
875
|
["/oauth/token", { method: "POST" }],
|
|
591
876
|
["/oauth/register", { method: "POST" }],
|
|
592
877
|
["/oauth/revoke", { method: "POST" }],
|
|
593
878
|
["/vaults", { method: "POST" }],
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
["/
|
|
879
|
+
// /login + /logout — canonical names since the auth-UX rename;
|
|
880
|
+
// /admin/login + /admin/logout 301-redirect to here (separate
|
|
881
|
+
// tests pin the redirects themselves).
|
|
882
|
+
["/login", { method: "POST" }],
|
|
883
|
+
["/logout", { method: "POST" }],
|
|
598
884
|
["/admin/host-admin-token", { method: "GET" }],
|
|
885
|
+
["/admin/vault-admin-token/demo", { method: "GET" }],
|
|
886
|
+
["/api/me", { method: "GET" }],
|
|
887
|
+
["/api/auth/mint-token", { method: "POST" }],
|
|
888
|
+
["/api/auth/revoke-token", { method: "POST" }],
|
|
889
|
+
["/api/auth/tokens", { method: "GET" }],
|
|
890
|
+
["/api/grants", { method: "GET" }],
|
|
891
|
+
["/api/grants/client-x", { method: "DELETE" }],
|
|
892
|
+
["/api/oauth/clients/client-x", { method: "GET" }],
|
|
893
|
+
["/api/oauth/clients/client-x/approve", { method: "POST" }],
|
|
599
894
|
];
|
|
600
895
|
for (const [path, init] of cases) {
|
|
601
896
|
const res = await fetch(req(path, init));
|
|
602
897
|
expect(res.status).toBe(503);
|
|
898
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
899
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
900
|
+
expect(body.error).toBe("service_unavailable");
|
|
901
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
603
902
|
}
|
|
604
903
|
} finally {
|
|
605
904
|
h.cleanup();
|
|
@@ -611,6 +910,11 @@ describe("hubFetch routing", () => {
|
|
|
611
910
|
try {
|
|
612
911
|
const db = openHubDb(hubDbPath(h.dir));
|
|
613
912
|
try {
|
|
913
|
+
// Seed an admin so the pre-admin setup gate (hub#258) doesn't
|
|
914
|
+
// 503 the request before the OAuth method-allow check runs.
|
|
915
|
+
// OAuth routing semantics are what this test pins; the setup
|
|
916
|
+
// gate has its own coverage in src/__tests__/setup-gate.test.ts.
|
|
917
|
+
await createUser(db, "owner", "pw");
|
|
614
918
|
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
615
919
|
req("/oauth/token", { method: "GET" }),
|
|
616
920
|
);
|
|
@@ -628,6 +932,7 @@ describe("hubFetch routing", () => {
|
|
|
628
932
|
try {
|
|
629
933
|
const db = openHubDb(hubDbPath(h.dir));
|
|
630
934
|
try {
|
|
935
|
+
await createUser(db, "owner", "pw");
|
|
631
936
|
const res = await hubFetch(h.dir, {
|
|
632
937
|
getDb: () => db,
|
|
633
938
|
issuer: "https://hub.example",
|
|
@@ -649,6 +954,162 @@ describe("hubFetch routing", () => {
|
|
|
649
954
|
}
|
|
650
955
|
});
|
|
651
956
|
|
|
957
|
+
// Platform health check (hub#258). Returns 200 JSON regardless of DB
|
|
958
|
+
// state — Render et al. poll this every few seconds and a transient DB
|
|
959
|
+
// open shouldn't cascade into a restart loop. The body advertises the
|
|
960
|
+
// running version so a deploy verifier can confirm the rolled-out
|
|
961
|
+
// image is the one it expected.
|
|
962
|
+
test("/health returns 200 JSON without invoking the db", async () => {
|
|
963
|
+
const h = makeHarness();
|
|
964
|
+
try {
|
|
965
|
+
const res = await hubFetch(h.dir, {
|
|
966
|
+
getDb: () => {
|
|
967
|
+
throw new Error("getDb must not be called by /health");
|
|
968
|
+
},
|
|
969
|
+
})(req("/health"));
|
|
970
|
+
expect(res.status).toBe(200);
|
|
971
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
972
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
973
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
974
|
+
expect(body.status).toBe("ok");
|
|
975
|
+
expect(body.service).toBe("parachute-hub");
|
|
976
|
+
expect(typeof body.version).toBe("string");
|
|
977
|
+
} finally {
|
|
978
|
+
h.cleanup();
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// First-boot setup placeholder (hub#258). When no admin exists the page
|
|
983
|
+
// is the bootstrap onboarding surface; once an admin exists it 301s to
|
|
984
|
+
// /login so a stale bookmark still lands somewhere useful.
|
|
985
|
+
test("/admin/setup renders placeholder HTML when no admin exists", async () => {
|
|
986
|
+
const h = makeHarness();
|
|
987
|
+
try {
|
|
988
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
989
|
+
try {
|
|
990
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
991
|
+
expect(res.status).toBe(200);
|
|
992
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
993
|
+
const body = await res.text();
|
|
994
|
+
expect(body).toContain("first-boot setup");
|
|
995
|
+
expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
|
|
996
|
+
} finally {
|
|
997
|
+
db.close();
|
|
998
|
+
}
|
|
999
|
+
} finally {
|
|
1000
|
+
h.cleanup();
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("/admin/setup 301s to /login when an admin already exists", async () => {
|
|
1005
|
+
const h = makeHarness();
|
|
1006
|
+
try {
|
|
1007
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1008
|
+
try {
|
|
1009
|
+
await createUser(db, "owner", "pw");
|
|
1010
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
1011
|
+
expect(res.status).toBe(301);
|
|
1012
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
1013
|
+
} finally {
|
|
1014
|
+
db.close();
|
|
1015
|
+
}
|
|
1016
|
+
} finally {
|
|
1017
|
+
h.cleanup();
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Pre-admin lockout (hub#258). When no admin row exists, operator-
|
|
1022
|
+
// facing surfaces (admin/api/login) 503 with a JSON body pointing at
|
|
1023
|
+
// /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
|
|
1024
|
+
// /admin/setup itself) stay open so the container is reachable and
|
|
1025
|
+
// OAuth third parties aren't held hostage by admin onboarding.
|
|
1026
|
+
test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
|
|
1027
|
+
const h = makeHarness();
|
|
1028
|
+
try {
|
|
1029
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1030
|
+
try {
|
|
1031
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
|
|
1032
|
+
expect(res.status).toBe(503);
|
|
1033
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1034
|
+
expect(body.error).toBe("setup_required");
|
|
1035
|
+
expect(body.setup_url).toBe("/admin/setup");
|
|
1036
|
+
} finally {
|
|
1037
|
+
db.close();
|
|
1038
|
+
}
|
|
1039
|
+
} finally {
|
|
1040
|
+
h.cleanup();
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
|
|
1045
|
+
const h = makeHarness();
|
|
1046
|
+
try {
|
|
1047
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1048
|
+
try {
|
|
1049
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1050
|
+
expect(res.status).toBe(503);
|
|
1051
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1052
|
+
expect(body.error).toBe("setup_required");
|
|
1053
|
+
} finally {
|
|
1054
|
+
db.close();
|
|
1055
|
+
}
|
|
1056
|
+
} finally {
|
|
1057
|
+
h.cleanup();
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
|
|
1062
|
+
const h = makeHarness();
|
|
1063
|
+
try {
|
|
1064
|
+
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
1065
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
1066
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1067
|
+
try {
|
|
1068
|
+
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
1069
|
+
// /login gated
|
|
1070
|
+
const loginRes = await handler(req("/login"));
|
|
1071
|
+
expect(loginRes.status).toBe(503);
|
|
1072
|
+
// /admin/setup open
|
|
1073
|
+
const setupRes = await handler(req("/admin/setup"));
|
|
1074
|
+
expect(setupRes.status).toBe(200);
|
|
1075
|
+
// /health open
|
|
1076
|
+
const healthRes = await handler(req("/health"));
|
|
1077
|
+
expect(healthRes.status).toBe(200);
|
|
1078
|
+
// / open
|
|
1079
|
+
const rootRes = await handler(req("/"));
|
|
1080
|
+
expect(rootRes.status).toBe(200);
|
|
1081
|
+
// /.well-known/parachute.json open
|
|
1082
|
+
const wkRes = await handler(req("/.well-known/parachute.json"));
|
|
1083
|
+
expect(wkRes.status).toBe(200);
|
|
1084
|
+
} finally {
|
|
1085
|
+
db.close();
|
|
1086
|
+
}
|
|
1087
|
+
} finally {
|
|
1088
|
+
h.cleanup();
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("pre-admin lockout falls away once an admin exists", async () => {
|
|
1093
|
+
const h = makeHarness();
|
|
1094
|
+
try {
|
|
1095
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1096
|
+
try {
|
|
1097
|
+
// Before: /api/me 503s under the lockout.
|
|
1098
|
+
const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1099
|
+
expect(before.status).toBe(503);
|
|
1100
|
+
// After seeding an admin: dispatch resumes normal handling.
|
|
1101
|
+
await createUser(db, "owner", "pw");
|
|
1102
|
+
const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1103
|
+
// /api/me with no session returns `hasSession: false` 200, not 503.
|
|
1104
|
+
expect(after.status).toBe(200);
|
|
1105
|
+
} finally {
|
|
1106
|
+
db.close();
|
|
1107
|
+
}
|
|
1108
|
+
} finally {
|
|
1109
|
+
h.cleanup();
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
652
1113
|
test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
|
|
653
1114
|
const h = makeHarness();
|
|
654
1115
|
try {
|
|
@@ -1024,15 +1485,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1024
1485
|
}
|
|
1025
1486
|
});
|
|
1026
1487
|
|
|
1027
|
-
test("single-segment /vault/<name> picks proxy when registered,
|
|
1488
|
+
test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
|
|
1028
1489
|
// Two cases share one fixture so the contrast is explicit:
|
|
1029
1490
|
// - `/vault/default` is registered → proxy answers (200, JSON tag).
|
|
1030
|
-
// - `/vault/nonexistent` has no match →
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1491
|
+
// - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
|
|
1492
|
+
// fallback under /vault since hub#231 moved the admin SPA to
|
|
1493
|
+
// /admin/*; the /vault/<name>/* slot is now exclusively the
|
|
1494
|
+
// per-vault content proxy).
|
|
1033
1495
|
// This is the routing-order seam #173 introduced — proxy is consulted
|
|
1034
|
-
// before the SPA fallback
|
|
1035
|
-
// vault claims the path.
|
|
1496
|
+
// before the 404; the SPA fallback that used to live here is gone.
|
|
1036
1497
|
const h = makeHarness();
|
|
1037
1498
|
const upstream = startUpstream("default-vault");
|
|
1038
1499
|
try {
|
|
@@ -1065,10 +1526,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1065
1526
|
expect(body.tag).toBe("default-vault");
|
|
1066
1527
|
expect(body.pathname).toBe("/vault/default");
|
|
1067
1528
|
|
|
1068
|
-
const
|
|
1069
|
-
expect(
|
|
1070
|
-
expect(shelled.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
1071
|
-
expect(await shelled.text()).toContain("<div id=root>");
|
|
1529
|
+
const notFound = await fetcher(req("/vault/nonexistent"));
|
|
1530
|
+
expect(notFound.status).toBe(404);
|
|
1072
1531
|
} finally {
|
|
1073
1532
|
upstream.stop();
|
|
1074
1533
|
h.cleanup();
|