@openparachute/hub 0.5.7 → 0.5.10-rc.10
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-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -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__/csrf.test.ts +40 -1
- 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 +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- 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 +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -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-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -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-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- 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 +262 -56
- 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/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -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,64 @@ 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
|
+
//
|
|
78
|
+
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
79
|
+
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
80
|
+
// test continues to exercise the signed-out-but-setup-done branch.
|
|
81
|
+
const h = makeHarness();
|
|
82
|
+
try {
|
|
83
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
84
|
+
try {
|
|
85
|
+
const { createUser } = await import("../users.ts");
|
|
86
|
+
await createUser(db, "owner", "pw");
|
|
87
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
90
|
+
const body = await res.text();
|
|
91
|
+
expect(body).toContain('class="auth-indicator"');
|
|
92
|
+
expect(body).toContain("Sign in");
|
|
93
|
+
expect(body).not.toContain("Signed in as");
|
|
94
|
+
} finally {
|
|
95
|
+
db.close();
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
h.cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
103
|
+
const h = makeHarness();
|
|
104
|
+
try {
|
|
105
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
106
|
+
try {
|
|
107
|
+
const { createUser } = await import("../users.ts");
|
|
108
|
+
const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import(
|
|
109
|
+
"../sessions.ts"
|
|
110
|
+
);
|
|
111
|
+
const user = await createUser(db, "aaron", "pw");
|
|
112
|
+
const session = createSession(db, { userId: user.id });
|
|
113
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
114
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/", { headers: { cookie } }));
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
const body = await res.text();
|
|
117
|
+
expect(body).toContain("Signed in as");
|
|
118
|
+
expect(body).toContain("aaron");
|
|
119
|
+
expect(body).toContain('action="/logout"');
|
|
120
|
+
expect(body).toContain('name="__csrf"');
|
|
121
|
+
// CSRF cookie was minted on the response (no prior cookie present).
|
|
122
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_csrf=");
|
|
123
|
+
} finally {
|
|
124
|
+
db.close();
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
h.cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
72
131
|
test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
|
|
73
132
|
const h = makeHarness();
|
|
74
133
|
try {
|
|
@@ -106,6 +165,29 @@ describe("hubFetch routing", () => {
|
|
|
106
165
|
}
|
|
107
166
|
});
|
|
108
167
|
|
|
168
|
+
test("/.well-known/parachute.json sets cache-control: no-store (hub#268 Item 1)", async () => {
|
|
169
|
+
// The discovery page (/) fetches this doc and renders Service tiles
|
|
170
|
+
// from it. Without no-store, the browser HTTP cache returns the
|
|
171
|
+
// stale services list the next time the operator clicks back to /
|
|
172
|
+
// after installing a module via /admin/modules. The doc is built
|
|
173
|
+
// per-request anyway, so giving up cacheability has no real cost.
|
|
174
|
+
const h = makeHarness();
|
|
175
|
+
try {
|
|
176
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
177
|
+
const getRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
178
|
+
req("/.well-known/parachute.json"),
|
|
179
|
+
);
|
|
180
|
+
expect(getRes.headers.get("cache-control")).toBe("no-store");
|
|
181
|
+
// Preflight gets the same header (same corsHeaders object).
|
|
182
|
+
const optRes = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
183
|
+
req("/.well-known/parachute.json", { method: "OPTIONS" }),
|
|
184
|
+
);
|
|
185
|
+
expect(optRes.headers.get("cache-control")).toBe("no-store");
|
|
186
|
+
} finally {
|
|
187
|
+
h.cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
109
191
|
test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
|
|
110
192
|
const h = makeHarness();
|
|
111
193
|
try {
|
|
@@ -194,6 +276,105 @@ describe("hubFetch routing", () => {
|
|
|
194
276
|
}
|
|
195
277
|
});
|
|
196
278
|
|
|
279
|
+
// Phase D consumer-side: each non-vault service entry's
|
|
280
|
+
// module.json:uiUrl + displayName ride through to doc.services. The
|
|
281
|
+
// discovery page (`/`) reads them to render data-driven Service tiles.
|
|
282
|
+
test("/.well-known/parachute.json surfaces uiUrl + displayName from non-vault module manifests", async () => {
|
|
283
|
+
const h = makeHarness();
|
|
284
|
+
try {
|
|
285
|
+
const notesEntry: ServiceEntry = {
|
|
286
|
+
name: "parachute-notes",
|
|
287
|
+
port: 5173,
|
|
288
|
+
paths: ["/notes"],
|
|
289
|
+
health: "/health",
|
|
290
|
+
version: "0.0.1",
|
|
291
|
+
installDir: "/fake/notes",
|
|
292
|
+
};
|
|
293
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
294
|
+
const res = await hubFetch(h.dir, {
|
|
295
|
+
manifestPath: h.manifestPath,
|
|
296
|
+
readModuleManifest: async () => ({
|
|
297
|
+
name: "notes",
|
|
298
|
+
manifestName: "parachute-notes",
|
|
299
|
+
kind: "frontend",
|
|
300
|
+
port: 5173,
|
|
301
|
+
paths: ["/notes"],
|
|
302
|
+
health: "/health",
|
|
303
|
+
uiUrl: "/notes",
|
|
304
|
+
displayName: "Notes",
|
|
305
|
+
}),
|
|
306
|
+
})(req("/.well-known/parachute.json"));
|
|
307
|
+
expect(res.status).toBe(200);
|
|
308
|
+
const body = (await res.json()) as {
|
|
309
|
+
services: Array<{ name: string; uiUrl?: string; displayName?: string }>;
|
|
310
|
+
};
|
|
311
|
+
const svc = body.services.find((s) => s.name === "parachute-notes");
|
|
312
|
+
expect(svc?.uiUrl).toMatch(/\/notes$/);
|
|
313
|
+
expect(svc?.displayName).toBe("Notes");
|
|
314
|
+
} finally {
|
|
315
|
+
h.cleanup();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("/.well-known/parachute.json omits uiUrl when the non-vault manifest has none", async () => {
|
|
320
|
+
const h = makeHarness();
|
|
321
|
+
try {
|
|
322
|
+
const notesEntry: ServiceEntry = {
|
|
323
|
+
name: "parachute-notes",
|
|
324
|
+
port: 5173,
|
|
325
|
+
paths: ["/notes"],
|
|
326
|
+
health: "/health",
|
|
327
|
+
version: "0.0.1",
|
|
328
|
+
installDir: "/fake/notes",
|
|
329
|
+
};
|
|
330
|
+
writeManifest({ services: [notesEntry] }, h.manifestPath);
|
|
331
|
+
const res = await hubFetch(h.dir, {
|
|
332
|
+
manifestPath: h.manifestPath,
|
|
333
|
+
readModuleManifest: async () => ({
|
|
334
|
+
name: "notes",
|
|
335
|
+
manifestName: "parachute-notes",
|
|
336
|
+
kind: "frontend",
|
|
337
|
+
port: 5173,
|
|
338
|
+
paths: ["/notes"],
|
|
339
|
+
health: "/health",
|
|
340
|
+
// no uiUrl declared — discovery page will skip the tile.
|
|
341
|
+
}),
|
|
342
|
+
})(req("/.well-known/parachute.json"));
|
|
343
|
+
const body = (await res.json()) as { services: Array<{ uiUrl?: string }> };
|
|
344
|
+
expect(body.services[0]).not.toHaveProperty("uiUrl");
|
|
345
|
+
} finally {
|
|
346
|
+
h.cleanup();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
|
|
351
|
+
const h = makeHarness();
|
|
352
|
+
try {
|
|
353
|
+
const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
|
|
354
|
+
writeManifest({ services: [vaultWithDir] }, h.manifestPath);
|
|
355
|
+
// The fake module.json declares uiUrl, but vault is supposed to be
|
|
356
|
+
// skipped by loadServiceUiMetadata (it has its own managementUrl
|
|
357
|
+
// path). So doc.services[vault] should NOT carry uiUrl.
|
|
358
|
+
const res = await hubFetch(h.dir, {
|
|
359
|
+
manifestPath: h.manifestPath,
|
|
360
|
+
readModuleManifest: async () => ({
|
|
361
|
+
name: "vault",
|
|
362
|
+
manifestName: "parachute-vault",
|
|
363
|
+
kind: "api",
|
|
364
|
+
port: 1940,
|
|
365
|
+
paths: ["/vault/default"],
|
|
366
|
+
health: "/health",
|
|
367
|
+
uiUrl: "/should-be-ignored",
|
|
368
|
+
}),
|
|
369
|
+
})(req("/.well-known/parachute.json"));
|
|
370
|
+
const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
|
|
371
|
+
const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
|
|
372
|
+
expect(vaultSvc).not.toHaveProperty("uiUrl");
|
|
373
|
+
} finally {
|
|
374
|
+
h.cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
197
378
|
// The bug this PR fixes: `parachute vault create techne` updates
|
|
198
379
|
// services.json but the old code only re-derived parachute.json on
|
|
199
380
|
// `parachute expose`. With the dynamic build, the second GET reflects
|
|
@@ -398,15 +579,12 @@ describe("hubFetch routing", () => {
|
|
|
398
579
|
}
|
|
399
580
|
});
|
|
400
581
|
|
|
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.
|
|
582
|
+
// SPA mount after hub#231: single `/admin/*` mount serves vault
|
|
583
|
+
// provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
|
|
584
|
+
// SPA URLs are 301-redirected; the per-vault content proxy at
|
|
585
|
+
// `/vault/<name>/*` stays where it is.
|
|
408
586
|
|
|
409
|
-
test("/
|
|
587
|
+
test("/admin/vaults serves the SPA shell when the bundle exists", async () => {
|
|
410
588
|
const h = makeHarness();
|
|
411
589
|
try {
|
|
412
590
|
const dist = join(h.dir, "dist");
|
|
@@ -414,7 +592,7 @@ describe("hubFetch routing", () => {
|
|
|
414
592
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
415
593
|
writeManifest({ services: [] }, h.manifestPath);
|
|
416
594
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
417
|
-
req("/
|
|
595
|
+
req("/admin/vaults"),
|
|
418
596
|
);
|
|
419
597
|
expect(res.status).toBe(200);
|
|
420
598
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -424,10 +602,7 @@ describe("hubFetch routing", () => {
|
|
|
424
602
|
}
|
|
425
603
|
});
|
|
426
604
|
|
|
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.
|
|
605
|
+
test("/admin/vaults/new serves the SPA shell (client-side route)", async () => {
|
|
431
606
|
const h = makeHarness();
|
|
432
607
|
try {
|
|
433
608
|
const dist = join(h.dir, "dist");
|
|
@@ -435,7 +610,7 @@ describe("hubFetch routing", () => {
|
|
|
435
610
|
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
436
611
|
writeManifest({ services: [] }, h.manifestPath);
|
|
437
612
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
438
|
-
req("/
|
|
613
|
+
req("/admin/vaults/new"),
|
|
439
614
|
);
|
|
440
615
|
expect(res.status).toBe(200);
|
|
441
616
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -445,7 +620,34 @@ describe("hubFetch routing", () => {
|
|
|
445
620
|
}
|
|
446
621
|
});
|
|
447
622
|
|
|
448
|
-
test("/
|
|
623
|
+
test("/admin/permissions serves the SPA shell", async () => {
|
|
624
|
+
const h = makeHarness();
|
|
625
|
+
try {
|
|
626
|
+
const dist = join(h.dir, "dist");
|
|
627
|
+
mkdirIfMissing(dist);
|
|
628
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
629
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/permissions"));
|
|
630
|
+
expect(res.status).toBe(200);
|
|
631
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
632
|
+
} finally {
|
|
633
|
+
h.cleanup();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("/admin/tokens serves the SPA shell", async () => {
|
|
638
|
+
const h = makeHarness();
|
|
639
|
+
try {
|
|
640
|
+
const dist = join(h.dir, "dist");
|
|
641
|
+
mkdirIfMissing(dist);
|
|
642
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><div id=root></div>");
|
|
643
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(req("/admin/tokens"));
|
|
644
|
+
expect(res.status).toBe(200);
|
|
645
|
+
} finally {
|
|
646
|
+
h.cleanup();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("/admin/assets/*.js is served with the matching content-type", async () => {
|
|
449
651
|
const h = makeHarness();
|
|
450
652
|
try {
|
|
451
653
|
const dist = join(h.dir, "dist");
|
|
@@ -456,7 +658,7 @@ describe("hubFetch routing", () => {
|
|
|
456
658
|
writeFileSync(join(assets, "main.js"), "console.log('hi');");
|
|
457
659
|
writeManifest({ services: [] }, h.manifestPath);
|
|
458
660
|
const res = await hubFetch(h.dir, { spaDistDir: dist, manifestPath: h.manifestPath })(
|
|
459
|
-
req("/
|
|
661
|
+
req("/admin/assets/main.js"),
|
|
460
662
|
);
|
|
461
663
|
expect(res.status).toBe(200);
|
|
462
664
|
expect(res.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
|
|
@@ -466,14 +668,14 @@ describe("hubFetch routing", () => {
|
|
|
466
668
|
}
|
|
467
669
|
});
|
|
468
670
|
|
|
469
|
-
test("/
|
|
671
|
+
test("/admin/* returns 503 with build hint when dist is missing", async () => {
|
|
470
672
|
const h = makeHarness();
|
|
471
673
|
try {
|
|
472
674
|
writeManifest({ services: [] }, h.manifestPath);
|
|
473
675
|
const res = await hubFetch(h.dir, {
|
|
474
676
|
spaDistDir: join(h.dir, "missing"),
|
|
475
677
|
manifestPath: h.manifestPath,
|
|
476
|
-
})(req("/
|
|
678
|
+
})(req("/admin/vaults"));
|
|
477
679
|
expect(res.status).toBe(503);
|
|
478
680
|
expect(await res.text()).toContain("bun run build");
|
|
479
681
|
} finally {
|
|
@@ -481,125 +683,251 @@ describe("hubFetch routing", () => {
|
|
|
481
683
|
}
|
|
482
684
|
});
|
|
483
685
|
|
|
484
|
-
test("/
|
|
686
|
+
test("/admin/vaults rejects non-GET methods with 405", async () => {
|
|
485
687
|
const h = makeHarness();
|
|
486
688
|
try {
|
|
487
689
|
const dist = join(h.dir, "dist");
|
|
488
690
|
mkdirIfMissing(dist);
|
|
489
691
|
writeFileSync(join(dist, "index.html"), "<!doctype html>");
|
|
490
|
-
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
692
|
+
const res = await hubFetch(h.dir, { spaDistDir: dist })(
|
|
693
|
+
req("/admin/vaults", { method: "POST" }),
|
|
694
|
+
);
|
|
491
695
|
expect(res.status).toBe(405);
|
|
492
696
|
} finally {
|
|
493
697
|
h.cleanup();
|
|
494
698
|
}
|
|
495
699
|
});
|
|
496
700
|
|
|
497
|
-
|
|
701
|
+
// 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
|
|
702
|
+
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
703
|
+
// dispatch — operator bookmarks landing on any of these still work.
|
|
704
|
+
|
|
705
|
+
test("301: /vault → /admin/vaults", async () => {
|
|
498
706
|
const h = makeHarness();
|
|
499
707
|
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>");
|
|
708
|
+
const res = await hubFetch(h.dir)(req("/vault"));
|
|
709
|
+
expect(res.status).toBe(301);
|
|
710
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
507
711
|
} finally {
|
|
508
712
|
h.cleanup();
|
|
509
713
|
}
|
|
510
714
|
});
|
|
511
715
|
|
|
512
|
-
test("/
|
|
716
|
+
test("301: /vault/new → /admin/vaults/new", async () => {
|
|
513
717
|
const h = makeHarness();
|
|
514
718
|
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");
|
|
719
|
+
const res = await hubFetch(h.dir)(req("/vault/new"));
|
|
720
|
+
expect(res.status).toBe(301);
|
|
721
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
520
722
|
} finally {
|
|
521
723
|
h.cleanup();
|
|
522
724
|
}
|
|
523
725
|
});
|
|
524
726
|
|
|
525
|
-
test("/
|
|
727
|
+
test("301: /vault preserves query string", async () => {
|
|
526
728
|
const h = makeHarness();
|
|
527
729
|
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);
|
|
730
|
+
const res = await hubFetch(h.dir)(req("/vault?next=foo"));
|
|
731
|
+
expect(res.status).toBe(301);
|
|
732
|
+
expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
|
|
535
733
|
} finally {
|
|
536
734
|
h.cleanup();
|
|
537
735
|
}
|
|
538
736
|
});
|
|
539
737
|
|
|
540
|
-
test("/hub/vaults
|
|
541
|
-
//
|
|
542
|
-
//
|
|
738
|
+
test("301: /hub/vaults → /admin/vaults (chain through the rename)", async () => {
|
|
739
|
+
// The /hub/vaults redirect predates #231 — it used to land at /vault.
|
|
740
|
+
// Now it lands at the final /admin/vaults so old bookmarks don't bounce
|
|
741
|
+
// through two redirects.
|
|
543
742
|
const h = makeHarness();
|
|
544
743
|
try {
|
|
545
744
|
const res = await hubFetch(h.dir)(req("/hub/vaults"));
|
|
546
745
|
expect(res.status).toBe(301);
|
|
547
|
-
expect(res.headers.get("location")).toBe("/
|
|
746
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
548
747
|
} finally {
|
|
549
748
|
h.cleanup();
|
|
550
749
|
}
|
|
551
750
|
});
|
|
552
751
|
|
|
553
|
-
test("/hub/vaults/new
|
|
752
|
+
test("301: /hub/vaults/new → /admin/vaults/new", async () => {
|
|
554
753
|
const h = makeHarness();
|
|
555
754
|
try {
|
|
556
755
|
const res = await hubFetch(h.dir)(req("/hub/vaults/new"));
|
|
557
756
|
expect(res.status).toBe(301);
|
|
558
|
-
expect(res.headers.get("location")).toBe("/
|
|
757
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/new");
|
|
559
758
|
} finally {
|
|
560
759
|
h.cleanup();
|
|
561
760
|
}
|
|
562
761
|
});
|
|
563
762
|
|
|
564
|
-
test("/hub/vaults/* preserves the query string
|
|
763
|
+
test("301: /hub/vaults/* preserves the query string", async () => {
|
|
565
764
|
const h = makeHarness();
|
|
566
765
|
try {
|
|
567
766
|
const res = await hubFetch(h.dir)(req("/hub/vaults/foo?bar=1&baz=2"));
|
|
568
767
|
expect(res.status).toBe(301);
|
|
569
|
-
expect(res.headers.get("location")).toBe("/
|
|
768
|
+
expect(res.headers.get("location")).toBe("/admin/vaults/foo?bar=1&baz=2");
|
|
769
|
+
} finally {
|
|
770
|
+
h.cleanup();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("301: /hub/permissions → /admin/permissions", async () => {
|
|
775
|
+
const h = makeHarness();
|
|
776
|
+
try {
|
|
777
|
+
const res = await hubFetch(h.dir)(req("/hub/permissions"));
|
|
778
|
+
expect(res.status).toBe(301);
|
|
779
|
+
expect(res.headers.get("location")).toBe("/admin/permissions");
|
|
780
|
+
} finally {
|
|
781
|
+
h.cleanup();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("301: /hub/tokens → /admin/tokens", async () => {
|
|
786
|
+
const h = makeHarness();
|
|
787
|
+
try {
|
|
788
|
+
const res = await hubFetch(h.dir)(req("/hub/tokens"));
|
|
789
|
+
expect(res.status).toBe(301);
|
|
790
|
+
expect(res.headers.get("location")).toBe("/admin/tokens");
|
|
791
|
+
} finally {
|
|
792
|
+
h.cleanup();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("301: /hub bare → /admin/vaults", async () => {
|
|
797
|
+
const h = makeHarness();
|
|
798
|
+
try {
|
|
799
|
+
const res = await hubFetch(h.dir)(req("/hub"));
|
|
800
|
+
expect(res.status).toBe(301);
|
|
801
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
802
|
+
} finally {
|
|
803
|
+
h.cleanup();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Login surface rename redirects (auth-UX cleanup): /admin/login and
|
|
808
|
+
// /admin/logout 301 to /login and /logout. Path-only test — the
|
|
809
|
+
// handlers themselves are exercised through the existing
|
|
810
|
+
// handleAdminLoginGet/Post + handleAdminLogoutPost test files.
|
|
811
|
+
test("301: /admin/login → /login", async () => {
|
|
812
|
+
const h = makeHarness();
|
|
813
|
+
try {
|
|
814
|
+
const res = await hubFetch(h.dir)(req("/admin/login"));
|
|
815
|
+
expect(res.status).toBe(301);
|
|
816
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
817
|
+
} finally {
|
|
818
|
+
h.cleanup();
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("301: /admin/login preserves the next= query param", async () => {
|
|
823
|
+
const h = makeHarness();
|
|
824
|
+
try {
|
|
825
|
+
const res = await hubFetch(h.dir)(req("/admin/login?next=/admin/permissions"));
|
|
826
|
+
expect(res.status).toBe(301);
|
|
827
|
+
expect(res.headers.get("location")).toBe("/login?next=/admin/permissions");
|
|
828
|
+
} finally {
|
|
829
|
+
h.cleanup();
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("301: /admin/config → /admin/vaults (legacy server-rendered portal retired)", async () => {
|
|
834
|
+
const h = makeHarness();
|
|
835
|
+
try {
|
|
836
|
+
const res = await hubFetch(h.dir)(req("/admin/config"));
|
|
837
|
+
expect(res.status).toBe(301);
|
|
838
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
839
|
+
} finally {
|
|
840
|
+
h.cleanup();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("301: /admin/config/<name> → /admin/vaults", async () => {
|
|
845
|
+
const h = makeHarness();
|
|
846
|
+
try {
|
|
847
|
+
const res = await hubFetch(h.dir)(req("/admin/config/vault"));
|
|
848
|
+
expect(res.status).toBe(301);
|
|
849
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
850
|
+
} finally {
|
|
851
|
+
h.cleanup();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("301: /admin/logout → /logout", async () => {
|
|
856
|
+
const h = makeHarness();
|
|
857
|
+
try {
|
|
858
|
+
const res = await hubFetch(h.dir)(req("/admin/logout"));
|
|
859
|
+
expect(res.status).toBe(301);
|
|
860
|
+
expect(res.headers.get("location")).toBe("/logout");
|
|
861
|
+
} finally {
|
|
862
|
+
h.cleanup();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
|
|
867
|
+
const h = makeHarness();
|
|
868
|
+
try {
|
|
869
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
870
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
871
|
+
req("/hub/unknown-thing"),
|
|
872
|
+
);
|
|
873
|
+
expect(res.status).toBe(404);
|
|
570
874
|
} finally {
|
|
571
875
|
h.cleanup();
|
|
572
876
|
}
|
|
573
877
|
});
|
|
574
878
|
|
|
575
|
-
test("/oauth/authorize without configured db returns 503", async () => {
|
|
879
|
+
test("/oauth/authorize without configured db returns 503 JSON", async () => {
|
|
576
880
|
const h = makeHarness();
|
|
577
881
|
try {
|
|
578
882
|
const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
|
|
579
883
|
expect(res.status).toBe(503);
|
|
884
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
885
|
+
expect(body.error).toBe("service_unavailable");
|
|
886
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
580
887
|
} finally {
|
|
581
888
|
h.cleanup();
|
|
582
889
|
}
|
|
583
890
|
});
|
|
584
891
|
|
|
585
|
-
test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
|
|
892
|
+
test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
|
|
586
893
|
const h = makeHarness();
|
|
587
894
|
try {
|
|
588
895
|
const fetch = hubFetch(h.dir);
|
|
896
|
+
// Every DB-dependent guard returns the same JSON 503 shape
|
|
897
|
+
// (`service_unavailable`) so consumers don't branch on content-type to
|
|
898
|
+
// extract the message. The pattern was already canonical on
|
|
899
|
+
// /api/auth/* (hub#215, #226) and was extended to all guards in
|
|
900
|
+
// hub#227.
|
|
589
901
|
const cases: Array<[string, RequestInit]> = [
|
|
902
|
+
["/oauth/authorize?client_id=x", { method: "GET" }],
|
|
903
|
+
["/oauth/authorize/approve", { method: "POST" }],
|
|
590
904
|
["/oauth/token", { method: "POST" }],
|
|
591
905
|
["/oauth/register", { method: "POST" }],
|
|
592
906
|
["/oauth/revoke", { method: "POST" }],
|
|
593
907
|
["/vaults", { method: "POST" }],
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
["/
|
|
908
|
+
// /login + /logout — canonical names since the auth-UX rename;
|
|
909
|
+
// /admin/login + /admin/logout 301-redirect to here (separate
|
|
910
|
+
// tests pin the redirects themselves).
|
|
911
|
+
["/login", { method: "POST" }],
|
|
912
|
+
["/logout", { method: "POST" }],
|
|
598
913
|
["/admin/host-admin-token", { method: "GET" }],
|
|
914
|
+
["/admin/vault-admin-token/demo", { method: "GET" }],
|
|
915
|
+
["/api/me", { method: "GET" }],
|
|
916
|
+
["/api/auth/mint-token", { method: "POST" }],
|
|
917
|
+
["/api/auth/revoke-token", { method: "POST" }],
|
|
918
|
+
["/api/auth/tokens", { method: "GET" }],
|
|
919
|
+
["/api/grants", { method: "GET" }],
|
|
920
|
+
["/api/grants/client-x", { method: "DELETE" }],
|
|
921
|
+
["/api/oauth/clients/client-x", { method: "GET" }],
|
|
922
|
+
["/api/oauth/clients/client-x/approve", { method: "POST" }],
|
|
599
923
|
];
|
|
600
924
|
for (const [path, init] of cases) {
|
|
601
925
|
const res = await fetch(req(path, init));
|
|
602
926
|
expect(res.status).toBe(503);
|
|
927
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
928
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
929
|
+
expect(body.error).toBe("service_unavailable");
|
|
930
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
603
931
|
}
|
|
604
932
|
} finally {
|
|
605
933
|
h.cleanup();
|
|
@@ -611,6 +939,11 @@ describe("hubFetch routing", () => {
|
|
|
611
939
|
try {
|
|
612
940
|
const db = openHubDb(hubDbPath(h.dir));
|
|
613
941
|
try {
|
|
942
|
+
// Seed an admin so the pre-admin setup gate (hub#258) doesn't
|
|
943
|
+
// 503 the request before the OAuth method-allow check runs.
|
|
944
|
+
// OAuth routing semantics are what this test pins; the setup
|
|
945
|
+
// gate has its own coverage in src/__tests__/setup-gate.test.ts.
|
|
946
|
+
await createUser(db, "owner", "pw");
|
|
614
947
|
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
615
948
|
req("/oauth/token", { method: "GET" }),
|
|
616
949
|
);
|
|
@@ -628,6 +961,7 @@ describe("hubFetch routing", () => {
|
|
|
628
961
|
try {
|
|
629
962
|
const db = openHubDb(hubDbPath(h.dir));
|
|
630
963
|
try {
|
|
964
|
+
await createUser(db, "owner", "pw");
|
|
631
965
|
const res = await hubFetch(h.dir, {
|
|
632
966
|
getDb: () => db,
|
|
633
967
|
issuer: "https://hub.example",
|
|
@@ -649,6 +983,191 @@ describe("hubFetch routing", () => {
|
|
|
649
983
|
}
|
|
650
984
|
});
|
|
651
985
|
|
|
986
|
+
// Platform health check (hub#258). Returns 200 JSON regardless of DB
|
|
987
|
+
// state — Render et al. poll this every few seconds and a transient DB
|
|
988
|
+
// open shouldn't cascade into a restart loop. The body advertises the
|
|
989
|
+
// running version so a deploy verifier can confirm the rolled-out
|
|
990
|
+
// image is the one it expected.
|
|
991
|
+
test("/health returns 200 JSON without invoking the db", async () => {
|
|
992
|
+
const h = makeHarness();
|
|
993
|
+
try {
|
|
994
|
+
const res = await hubFetch(h.dir, {
|
|
995
|
+
getDb: () => {
|
|
996
|
+
throw new Error("getDb must not be called by /health");
|
|
997
|
+
},
|
|
998
|
+
})(req("/health"));
|
|
999
|
+
expect(res.status).toBe(200);
|
|
1000
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
1001
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1002
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1003
|
+
expect(body.status).toBe("ok");
|
|
1004
|
+
expect(body.service).toBe("parachute-hub");
|
|
1005
|
+
expect(typeof body.version).toBe("string");
|
|
1006
|
+
} finally {
|
|
1007
|
+
h.cleanup();
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// First-boot setup wizard (hub#259, expanding hub#258's static
|
|
1012
|
+
// placeholder). When no admin exists, GET /admin/setup renders the
|
|
1013
|
+
// wizard's account-step form. Once admin + vault both exist, it 301s
|
|
1014
|
+
// to /login so a stale bookmark still lands somewhere useful. With
|
|
1015
|
+
// admin but no vault, the wizard resumes at the vault step.
|
|
1016
|
+
test("/admin/setup renders the wizard's account form when no admin exists", async () => {
|
|
1017
|
+
const h = makeHarness();
|
|
1018
|
+
try {
|
|
1019
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1020
|
+
try {
|
|
1021
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
1022
|
+
expect(res.status).toBe(200);
|
|
1023
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
1024
|
+
const body = await res.text();
|
|
1025
|
+
expect(body).toContain('action="/admin/setup/account"');
|
|
1026
|
+
// Env-var seed path is still surfaced as the alt-path disclosure.
|
|
1027
|
+
expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
|
|
1028
|
+
} finally {
|
|
1029
|
+
db.close();
|
|
1030
|
+
}
|
|
1031
|
+
} finally {
|
|
1032
|
+
h.cleanup();
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("/admin/setup 301s to /login once admin + vault + expose mode all exist (hub#259, hub#268)", async () => {
|
|
1037
|
+
const h = makeHarness();
|
|
1038
|
+
try {
|
|
1039
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1040
|
+
try {
|
|
1041
|
+
await createUser(db, "owner", "pw");
|
|
1042
|
+
// Seed the vault entry + expose-mode answer so the wizard's
|
|
1043
|
+
// state derives as "done" and the GET 301s. Without expose
|
|
1044
|
+
// (hub#268 Item 2) the wizard would resume on the expose step.
|
|
1045
|
+
const { writeManifest } = await import("../services-manifest.ts");
|
|
1046
|
+
const { setSetting } = await import("../hub-settings.ts");
|
|
1047
|
+
const { join } = await import("node:path");
|
|
1048
|
+
writeManifest(
|
|
1049
|
+
{
|
|
1050
|
+
services: [
|
|
1051
|
+
{
|
|
1052
|
+
name: "parachute-vault",
|
|
1053
|
+
version: "0.1.0",
|
|
1054
|
+
port: 1940,
|
|
1055
|
+
paths: ["/vault/default"],
|
|
1056
|
+
health: "/health",
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
},
|
|
1060
|
+
join(h.dir, "services.json"),
|
|
1061
|
+
);
|
|
1062
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1063
|
+
const res = await hubFetch(h.dir, {
|
|
1064
|
+
getDb: () => db,
|
|
1065
|
+
manifestPath: join(h.dir, "services.json"),
|
|
1066
|
+
})(req("/admin/setup"));
|
|
1067
|
+
expect(res.status).toBe(301);
|
|
1068
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
1069
|
+
} finally {
|
|
1070
|
+
db.close();
|
|
1071
|
+
}
|
|
1072
|
+
} finally {
|
|
1073
|
+
h.cleanup();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Pre-admin lockout (hub#258). When no admin row exists, operator-
|
|
1078
|
+
// facing surfaces (admin/api/login) 503 with a JSON body pointing at
|
|
1079
|
+
// /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
|
|
1080
|
+
// /admin/setup itself) stay open so the container is reachable and
|
|
1081
|
+
// OAuth third parties aren't held hostage by admin onboarding.
|
|
1082
|
+
test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
|
|
1083
|
+
const h = makeHarness();
|
|
1084
|
+
try {
|
|
1085
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1086
|
+
try {
|
|
1087
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
|
|
1088
|
+
expect(res.status).toBe(503);
|
|
1089
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1090
|
+
expect(body.error).toBe("setup_required");
|
|
1091
|
+
expect(body.setup_url).toBe("/admin/setup");
|
|
1092
|
+
} finally {
|
|
1093
|
+
db.close();
|
|
1094
|
+
}
|
|
1095
|
+
} finally {
|
|
1096
|
+
h.cleanup();
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
|
|
1101
|
+
const h = makeHarness();
|
|
1102
|
+
try {
|
|
1103
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1106
|
+
expect(res.status).toBe(503);
|
|
1107
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1108
|
+
expect(body.error).toBe("setup_required");
|
|
1109
|
+
} finally {
|
|
1110
|
+
db.close();
|
|
1111
|
+
}
|
|
1112
|
+
} finally {
|
|
1113
|
+
h.cleanup();
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
|
|
1118
|
+
const h = makeHarness();
|
|
1119
|
+
try {
|
|
1120
|
+
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
1121
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
1122
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1123
|
+
try {
|
|
1124
|
+
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
1125
|
+
// /login gated
|
|
1126
|
+
const loginRes = await handler(req("/login"));
|
|
1127
|
+
expect(loginRes.status).toBe(503);
|
|
1128
|
+
// /admin/setup open
|
|
1129
|
+
const setupRes = await handler(req("/admin/setup"));
|
|
1130
|
+
expect(setupRes.status).toBe(200);
|
|
1131
|
+
// /health open
|
|
1132
|
+
const healthRes = await handler(req("/health"));
|
|
1133
|
+
expect(healthRes.status).toBe(200);
|
|
1134
|
+
// / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
|
|
1135
|
+
// static portal pre-setup is useless; redirect to setup).
|
|
1136
|
+
const rootRes = await handler(req("/"));
|
|
1137
|
+
expect(rootRes.status).toBe(302);
|
|
1138
|
+
expect(rootRes.headers.get("location")).toBe("/admin/setup");
|
|
1139
|
+
// /.well-known/parachute.json open
|
|
1140
|
+
const wkRes = await handler(req("/.well-known/parachute.json"));
|
|
1141
|
+
expect(wkRes.status).toBe(200);
|
|
1142
|
+
} finally {
|
|
1143
|
+
db.close();
|
|
1144
|
+
}
|
|
1145
|
+
} finally {
|
|
1146
|
+
h.cleanup();
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
test("pre-admin lockout falls away once an admin exists", async () => {
|
|
1151
|
+
const h = makeHarness();
|
|
1152
|
+
try {
|
|
1153
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1154
|
+
try {
|
|
1155
|
+
// Before: /api/me 503s under the lockout.
|
|
1156
|
+
const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1157
|
+
expect(before.status).toBe(503);
|
|
1158
|
+
// After seeding an admin: dispatch resumes normal handling.
|
|
1159
|
+
await createUser(db, "owner", "pw");
|
|
1160
|
+
const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1161
|
+
// /api/me with no session returns `hasSession: false` 200, not 503.
|
|
1162
|
+
expect(after.status).toBe(200);
|
|
1163
|
+
} finally {
|
|
1164
|
+
db.close();
|
|
1165
|
+
}
|
|
1166
|
+
} finally {
|
|
1167
|
+
h.cleanup();
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
652
1171
|
test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
|
|
653
1172
|
const h = makeHarness();
|
|
654
1173
|
try {
|
|
@@ -1024,15 +1543,15 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1024
1543
|
}
|
|
1025
1544
|
});
|
|
1026
1545
|
|
|
1027
|
-
test("single-segment /vault/<name> picks proxy when registered,
|
|
1546
|
+
test("single-segment /vault/<name> picks proxy when registered, 404 when not", async () => {
|
|
1028
1547
|
// Two cases share one fixture so the contrast is explicit:
|
|
1029
1548
|
// - `/vault/default` is registered → proxy answers (200, JSON tag).
|
|
1030
|
-
// - `/vault/nonexistent` has no match →
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1549
|
+
// - `/vault/nonexistent` has no match → 404 directly (no SPA-shell
|
|
1550
|
+
// fallback under /vault since hub#231 moved the admin SPA to
|
|
1551
|
+
// /admin/*; the /vault/<name>/* slot is now exclusively the
|
|
1552
|
+
// per-vault content proxy).
|
|
1033
1553
|
// This is the routing-order seam #173 introduced — proxy is consulted
|
|
1034
|
-
// before the SPA fallback
|
|
1035
|
-
// vault claims the path.
|
|
1554
|
+
// before the 404; the SPA fallback that used to live here is gone.
|
|
1036
1555
|
const h = makeHarness();
|
|
1037
1556
|
const upstream = startUpstream("default-vault");
|
|
1038
1557
|
try {
|
|
@@ -1065,10 +1584,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1065
1584
|
expect(body.tag).toBe("default-vault");
|
|
1066
1585
|
expect(body.pathname).toBe("/vault/default");
|
|
1067
1586
|
|
|
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>");
|
|
1587
|
+
const notFound = await fetcher(req("/vault/nonexistent"));
|
|
1588
|
+
expect(notFound.status).toBe(404);
|
|
1072
1589
|
} finally {
|
|
1073
1590
|
upstream.stop();
|
|
1074
1591
|
h.cleanup();
|