@openparachute/hub 0.5.10 → 0.5.12-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__/api-modules-ops.test.ts +283 -1
- package/src/__tests__/api-settings-hub-origin.test.ts +452 -0
- package/src/__tests__/bootstrap-token.test.ts +148 -0
- package/src/__tests__/hub-origin-resolution.test.ts +154 -0
- package/src/__tests__/hub-settings.test.ts +94 -0
- package/src/__tests__/oauth-ui.test.ts +117 -0
- package/src/__tests__/serve.test.ts +132 -1
- package/src/__tests__/setup-gate.test.ts +93 -0
- package/src/__tests__/setup-wizard.test.ts +392 -0
- package/src/api-modules-ops.ts +120 -1
- package/src/api-settings-hub-origin.ts +253 -0
- package/src/bootstrap-token.ts +153 -0
- package/src/commands/serve.ts +65 -1
- package/src/hub-server.ts +136 -18
- package/src/hub-settings.ts +53 -1
- package/src/oauth-ui.ts +45 -3
- package/src/setup-wizard.ts +178 -13
- package/src/well-known.ts +82 -1
- package/web/ui/dist/assets/index-BKFoB4gE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-XhxYXDT5.js +0 -61
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the issuer precedence chain introduced in hub#298:
|
|
3
|
+
*
|
|
4
|
+
* hub_settings.hub_origin → configuredIssuer (env/flag) → request origin
|
|
5
|
+
*
|
|
6
|
+
* Both `resolveIssuer` (the canonical resolver) and `resolveIssuerSource`
|
|
7
|
+
* (the SPA-facing attribution helper) are exercised together so a future
|
|
8
|
+
* precedence drift can't surface in one without the other.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
|
+
import { resolveIssuer, resolveIssuerSource } from "../hub-server.ts";
|
|
16
|
+
import { setHubOrigin } from "../hub-settings.ts";
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
let db: ReturnType<typeof openHubDb>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "hub-issuer-resolve-"));
|
|
23
|
+
db = openHubDb(hubDbPath(dir));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
db.close();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function req(url: string): Request {
|
|
31
|
+
return new Request(url, { method: "GET" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("resolveIssuer — precedence chain", () => {
|
|
35
|
+
test("falls back to request origin when no settings + no env", () => {
|
|
36
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
37
|
+
expect(got).toBe("http://127.0.0.1:1939");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("env wins over request origin (deploy-time setting)", () => {
|
|
41
|
+
const got = resolveIssuer(
|
|
42
|
+
req("http://127.0.0.1:1939/oauth/token"),
|
|
43
|
+
db,
|
|
44
|
+
"https://hub.from-env.example",
|
|
45
|
+
);
|
|
46
|
+
expect(got).toBe("https://hub.from-env.example");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("hub_settings wins over env (operator override)", () => {
|
|
50
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
51
|
+
const got = resolveIssuer(
|
|
52
|
+
req("http://127.0.0.1:1939/oauth/token"),
|
|
53
|
+
db,
|
|
54
|
+
"https://hub.from-env.example",
|
|
55
|
+
);
|
|
56
|
+
expect(got).toBe("https://hub.from-settings.example");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("hub_settings wins over request origin (no env)", () => {
|
|
60
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
61
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
62
|
+
expect(got).toBe("https://hub.from-settings.example");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("clearing hub_settings reverts to env precedence", () => {
|
|
66
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
67
|
+
setHubOrigin(db, null);
|
|
68
|
+
const got = resolveIssuer(
|
|
69
|
+
req("http://127.0.0.1:1939/oauth/token"),
|
|
70
|
+
db,
|
|
71
|
+
"https://hub.from-env.example",
|
|
72
|
+
);
|
|
73
|
+
expect(got).toBe("https://hub.from-env.example");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("clearing hub_settings + no env reverts to request origin", () => {
|
|
77
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
78
|
+
setHubOrigin(db, null);
|
|
79
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
80
|
+
expect(got).toBe("http://127.0.0.1:1939");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("undefined db (pre-config gate) falls through to env then request", () => {
|
|
84
|
+
// The wellknown / discovery surfaces may hit oauthDeps before a DB
|
|
85
|
+
// is wired; resolveIssuer must not throw — just skip the settings
|
|
86
|
+
// layer.
|
|
87
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/"), undefined, undefined);
|
|
88
|
+
expect(got).toBe("http://127.0.0.1:1939");
|
|
89
|
+
|
|
90
|
+
const gotEnv = resolveIssuer(
|
|
91
|
+
req("http://127.0.0.1:1939/"),
|
|
92
|
+
undefined,
|
|
93
|
+
"https://hub.from-env.example",
|
|
94
|
+
);
|
|
95
|
+
expect(gotEnv).toBe("https://hub.from-env.example");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("change takes effect on the very next request (no caching)", () => {
|
|
99
|
+
// Critical operator-facing behavior: the SPA save button must
|
|
100
|
+
// affect token mints on the subsequent OAuth request without a
|
|
101
|
+
// hub restart. Exercised by pulling resolveIssuer in sequence
|
|
102
|
+
// around a mid-flight setHubOrigin call.
|
|
103
|
+
const baseUrl = "http://127.0.0.1:1939/oauth/token";
|
|
104
|
+
|
|
105
|
+
// Pass 1 — no settings, no env → request origin.
|
|
106
|
+
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
107
|
+
|
|
108
|
+
// Mid-flight write.
|
|
109
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
110
|
+
|
|
111
|
+
// Pass 2 — settings wins immediately.
|
|
112
|
+
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("https://hub.example.com");
|
|
113
|
+
|
|
114
|
+
// Mid-flight clear.
|
|
115
|
+
setHubOrigin(db, null);
|
|
116
|
+
|
|
117
|
+
// Pass 3 — back to request origin.
|
|
118
|
+
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("resolveIssuerSource — attribution for SPA", () => {
|
|
123
|
+
test('"request" when nothing is configured', () => {
|
|
124
|
+
expect(resolveIssuerSource(db, undefined)).toBe("request");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('"env" when configuredIssuer is set + no settings row', () => {
|
|
128
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('"settings" when hub_settings row is set, even if env is also set', () => {
|
|
132
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
133
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("attribution matches resolved value across the chain", () => {
|
|
137
|
+
// Pair them up so a future change to one without the other gets
|
|
138
|
+
// caught — the SPA helper text says "from settings" iff the
|
|
139
|
+
// settings layer is what got returned.
|
|
140
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
141
|
+
const r1 = req("http://127.0.0.1:1939/oauth/token");
|
|
142
|
+
expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe("https://hub.example.com");
|
|
143
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
|
|
144
|
+
|
|
145
|
+
setHubOrigin(db, null);
|
|
146
|
+
expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe(
|
|
147
|
+
"https://hub.from-env.example",
|
|
148
|
+
);
|
|
149
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
|
|
150
|
+
|
|
151
|
+
expect(resolveIssuer(r1, db, undefined)).toBe("http://127.0.0.1:1939");
|
|
152
|
+
expect(resolveIssuerSource(db, undefined)).toBe("request");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
SETUP_EXPOSE_MODES,
|
|
19
19
|
consumeFirstClientAutoApproveWindow,
|
|
20
20
|
deleteSetting,
|
|
21
|
+
getHubOrigin,
|
|
21
22
|
getModuleInstallChannel,
|
|
22
23
|
getSetting,
|
|
23
24
|
isFirstClientAutoApproveWindowOpen,
|
|
24
25
|
isModuleInstallChannel,
|
|
25
26
|
isSetupExposeMode,
|
|
26
27
|
openFirstClientAutoApproveWindow,
|
|
28
|
+
setHubOrigin,
|
|
27
29
|
setModuleInstallChannel,
|
|
28
30
|
setSetting,
|
|
29
31
|
} from "../hub-settings.ts";
|
|
@@ -375,3 +377,95 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
375
377
|
}
|
|
376
378
|
});
|
|
377
379
|
});
|
|
380
|
+
|
|
381
|
+
describe("hub-settings — hub_origin (hub#298)", () => {
|
|
382
|
+
let dir: string;
|
|
383
|
+
beforeEach(() => {
|
|
384
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
385
|
+
});
|
|
386
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
387
|
+
|
|
388
|
+
test("getHubOrigin returns null when no row is present", () => {
|
|
389
|
+
const db = openHubDb(hubDbPath(dir));
|
|
390
|
+
try {
|
|
391
|
+
expect(getHubOrigin(db)).toBeNull();
|
|
392
|
+
} finally {
|
|
393
|
+
db.close();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("setHubOrigin then getHubOrigin round-trips the value", () => {
|
|
398
|
+
const db = openHubDb(hubDbPath(dir));
|
|
399
|
+
try {
|
|
400
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
401
|
+
expect(getHubOrigin(db)).toBe("https://hub.example.com");
|
|
402
|
+
} finally {
|
|
403
|
+
db.close();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("setHubOrigin overwrites an existing value", () => {
|
|
408
|
+
const db = openHubDb(hubDbPath(dir));
|
|
409
|
+
try {
|
|
410
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
411
|
+
setHubOrigin(db, "https://hub.other.example");
|
|
412
|
+
expect(getHubOrigin(db)).toBe("https://hub.other.example");
|
|
413
|
+
} finally {
|
|
414
|
+
db.close();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("setHubOrigin(null) clears the row → getHubOrigin returns null", () => {
|
|
419
|
+
const db = openHubDb(hubDbPath(dir));
|
|
420
|
+
try {
|
|
421
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
422
|
+
setHubOrigin(db, null);
|
|
423
|
+
expect(getHubOrigin(db)).toBeNull();
|
|
424
|
+
// Idempotent — a second clear on an already-absent row is a no-op.
|
|
425
|
+
setHubOrigin(db, null);
|
|
426
|
+
expect(getHubOrigin(db)).toBeNull();
|
|
427
|
+
} finally {
|
|
428
|
+
db.close();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('setHubOrigin("") is treated as null (no falsy-row footgun)', () => {
|
|
433
|
+
// An empty string would be a useless issuer + would cause source
|
|
434
|
+
// attribution to lie ("from settings" while no real value).
|
|
435
|
+
// Normalize at the write layer.
|
|
436
|
+
const db = openHubDb(hubDbPath(dir));
|
|
437
|
+
try {
|
|
438
|
+
setHubOrigin(db, "https://hub.example.com");
|
|
439
|
+
setHubOrigin(db, "");
|
|
440
|
+
expect(getHubOrigin(db)).toBeNull();
|
|
441
|
+
} finally {
|
|
442
|
+
db.close();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("does not auto-seed from env (unlike module_install_channel)", () => {
|
|
447
|
+
// The env var (PARACHUTE_HUB_ORIGIN) is a separate precedence layer
|
|
448
|
+
// in resolveIssuer (env wins when no settings row). Auto-seeding
|
|
449
|
+
// would collapse env → settings and lose the source attribution
|
|
450
|
+
// the SPA exposes ("from env" vs "from settings"). Verify no row
|
|
451
|
+
// appears just from reading.
|
|
452
|
+
const prior = process.env.PARACHUTE_HUB_ORIGIN;
|
|
453
|
+
process.env.PARACHUTE_HUB_ORIGIN = "https://hub.from-env.example";
|
|
454
|
+
try {
|
|
455
|
+
const db = openHubDb(hubDbPath(dir));
|
|
456
|
+
try {
|
|
457
|
+
expect(getHubOrigin(db)).toBeNull();
|
|
458
|
+
} finally {
|
|
459
|
+
db.close();
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
if (prior === undefined) {
|
|
463
|
+
// Bun's process.env supports the `[key]: undefined` shape
|
|
464
|
+
// (biome's noDelete rule preferred this over `delete`).
|
|
465
|
+
process.env.PARACHUTE_HUB_ORIGIN = undefined;
|
|
466
|
+
} else {
|
|
467
|
+
process.env.PARACHUTE_HUB_ORIGIN = prior;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -308,6 +308,26 @@ describe("substituteVaultDisplay", () => {
|
|
|
308
308
|
// consenting to literally.
|
|
309
309
|
expect(substituteVaultDisplay("vault:admin", "work")).toBe("vault:admin");
|
|
310
310
|
});
|
|
311
|
+
|
|
312
|
+
test("'*' → renders the wildcard display form (vault:*:<verb>)", () => {
|
|
313
|
+
// Approve-time rendering: no vault has been picked yet (the consent
|
|
314
|
+
// picker hasn't run), but rendering the raw `vault:read` form implies
|
|
315
|
+
// unrestricted full-vault access. The wildcard signals "scope will be
|
|
316
|
+
// narrowed to a specific vault at consent" — mirrors the SPA's
|
|
317
|
+
// `/admin/approve-client/<id>` view.
|
|
318
|
+
expect(substituteVaultDisplay("vault:read", "*")).toBe("vault:*:read");
|
|
319
|
+
expect(substituteVaultDisplay("vault:write", "*")).toBe("vault:*:write");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("'*' → non-vault scopes still pass through unchanged", () => {
|
|
323
|
+
expect(substituteVaultDisplay("scribe:transcribe", "*")).toBe("scribe:transcribe");
|
|
324
|
+
expect(substituteVaultDisplay("channel:send", "*")).toBe("channel:send");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("'*' → already-named vault scopes pass through (caller specified the vault)", () => {
|
|
328
|
+
expect(substituteVaultDisplay("vault:work:read", "*")).toBe("vault:work:read");
|
|
329
|
+
expect(substituteVaultDisplay("vault:other:write", "*")).toBe("vault:other:write");
|
|
330
|
+
});
|
|
311
331
|
});
|
|
312
332
|
|
|
313
333
|
describe("renderConsent displayVault substitution", () => {
|
|
@@ -442,6 +462,103 @@ describe("renderApprovePending unauthenticated CTAs", () => {
|
|
|
442
462
|
});
|
|
443
463
|
});
|
|
444
464
|
|
|
465
|
+
describe("renderApprovePending scope display (wildcard substitution)", () => {
|
|
466
|
+
const COMMON = {
|
|
467
|
+
clientName: "MyApp",
|
|
468
|
+
clientId: "client-xyz",
|
|
469
|
+
redirectUris: ["https://app.example/cb"],
|
|
470
|
+
hubOrigin: "https://hub.example.com",
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
test("unnamed vault scopes render with the wildcard form (vault:*:<verb>)", () => {
|
|
474
|
+
// The bug Aaron hit on rc.1: the server-rendered approve-pending page
|
|
475
|
+
// showed raw `vault:read` / `vault:write` rather than the resolved
|
|
476
|
+
// `vault:*:<verb>` form the SPA already used. Both the unauth viewer
|
|
477
|
+
// and the authenticated admin branch get the substituted display so
|
|
478
|
+
// they match the SPA's `/admin/approve-client/<id>` view.
|
|
479
|
+
const html = renderApprovePending({
|
|
480
|
+
...COMMON,
|
|
481
|
+
requestedScopes: ["vault:read", "vault:write"],
|
|
482
|
+
});
|
|
483
|
+
expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
|
|
484
|
+
expect(html).toMatch(/<code class="scope-name">vault:\*:write<\/code>/);
|
|
485
|
+
// Raw unnamed form must NOT appear in the rendered scope-row code
|
|
486
|
+
// blocks any more — that was the bug.
|
|
487
|
+
expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
|
|
488
|
+
expect(html).not.toMatch(/<code class="scope-name">vault:write<\/code>/);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("wildcard explanation surfaces below the scope list when any scope renders with `*`", () => {
|
|
492
|
+
const html = renderApprovePending({
|
|
493
|
+
...COMMON,
|
|
494
|
+
requestedScopes: ["vault:read"],
|
|
495
|
+
});
|
|
496
|
+
// The `<p class="scope-wildcard-note">…` element is what's conditional;
|
|
497
|
+
// `.scope-wildcard-note` as a CSS class name is always present in the
|
|
498
|
+
// stylesheet, so match the rendered element specifically.
|
|
499
|
+
expect(html).toMatch(/<p class="scope-wildcard-note">/);
|
|
500
|
+
expect(html).toContain("a specific vault is selected during sign-in");
|
|
501
|
+
expect(html).toContain("unbound shape");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("wildcard explanation is omitted when no scope renders with `*`", () => {
|
|
505
|
+
// All-non-vault: explanation absent.
|
|
506
|
+
const nonVault = renderApprovePending({
|
|
507
|
+
...COMMON,
|
|
508
|
+
requestedScopes: ["scribe:transcribe", "channel:send"],
|
|
509
|
+
});
|
|
510
|
+
expect(nonVault).not.toMatch(/<p class="scope-wildcard-note">/);
|
|
511
|
+
expect(nonVault).not.toContain("unbound shape");
|
|
512
|
+
|
|
513
|
+
// Already-named vault: explanation absent (vault already specified).
|
|
514
|
+
const named = renderApprovePending({
|
|
515
|
+
...COMMON,
|
|
516
|
+
requestedScopes: ["vault:work:read"],
|
|
517
|
+
});
|
|
518
|
+
expect(named).not.toMatch(/<p class="scope-wildcard-note">/);
|
|
519
|
+
expect(named).not.toContain("unbound shape");
|
|
520
|
+
|
|
521
|
+
// Empty scopes: explanation absent (no scope rows at all).
|
|
522
|
+
const empty = renderApprovePending({ ...COMMON, requestedScopes: [] });
|
|
523
|
+
expect(empty).not.toMatch(/<p class="scope-wildcard-note">/);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("already-named vault scopes still render as-is (no double substitution)", () => {
|
|
527
|
+
const html = renderApprovePending({
|
|
528
|
+
...COMMON,
|
|
529
|
+
requestedScopes: ["vault:work:read"],
|
|
530
|
+
});
|
|
531
|
+
expect(html).toMatch(/<code class="scope-name">vault:work:read<\/code>/);
|
|
532
|
+
// Pre-existing named scopes don't get mangled by the wildcard substitution.
|
|
533
|
+
expect(html).not.toMatch(/vault:\*:work/);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("mixed scopes render the wildcard explanation when ANY scope is unnamed-vault", () => {
|
|
537
|
+
const html = renderApprovePending({
|
|
538
|
+
...COMMON,
|
|
539
|
+
requestedScopes: ["scribe:transcribe", "vault:read"],
|
|
540
|
+
});
|
|
541
|
+
expect(html).toMatch(/<code class="scope-name">scribe:transcribe<\/code>/);
|
|
542
|
+
expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
|
|
543
|
+
expect(html).toMatch(/<p class="scope-wildcard-note">/);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("authenticated admin branch also gets the wildcard substitution + explanation", () => {
|
|
547
|
+
// Both branches of the page render through the same scope-display path
|
|
548
|
+
// — the inline-form admin viewer sees the same resolved scopes as the
|
|
549
|
+
// unauth viewer with the sign-in CTA.
|
|
550
|
+
const html = renderApprovePending({
|
|
551
|
+
...COMMON,
|
|
552
|
+
requestedScopes: ["vault:read", "vault:write"],
|
|
553
|
+
approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
|
|
554
|
+
});
|
|
555
|
+
expect(html).toContain('action="/oauth/authorize/approve"');
|
|
556
|
+
expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
|
|
557
|
+
expect(html).toMatch(/<code class="scope-name">vault:\*:write<\/code>/);
|
|
558
|
+
expect(html).toMatch(/<p class="scope-wildcard-note">/);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
445
562
|
describe("CSS / styling guarantees", () => {
|
|
446
563
|
test("does not load fonts from a third-party CDN (privacy)", () => {
|
|
447
564
|
const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
|
|
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-token.ts";
|
|
6
|
+
import {
|
|
7
|
+
formatBootstrapTokenBanner,
|
|
8
|
+
formatListeningBanner,
|
|
9
|
+
seedInitialAdminIfNeeded,
|
|
10
|
+
} from "../commands/serve.ts";
|
|
6
11
|
import { openHubDb } from "../hub-db.ts";
|
|
7
12
|
import { getUserByUsername, userCount } from "../users.ts";
|
|
8
13
|
|
|
@@ -105,3 +110,129 @@ describe("seedInitialAdminIfNeeded", () => {
|
|
|
105
110
|
expect(userCount(db)).toBe(0);
|
|
106
111
|
});
|
|
107
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("formatListeningBanner", () => {
|
|
115
|
+
const base = {
|
|
116
|
+
port: 1939,
|
|
117
|
+
configDir: "/home/op/.parachute",
|
|
118
|
+
dbPath: "/home/op/.parachute/hub.db",
|
|
119
|
+
issuer: undefined,
|
|
120
|
+
adminBootstrap: "exists",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
test("hostname 0.0.0.0 → display localhost + bound note", () => {
|
|
124
|
+
// Chrome refuses to navigate to `0.0.0.0`, and mixing it with
|
|
125
|
+
// `localhost` trips cross-origin errors. Operators paste the URL
|
|
126
|
+
// straight from the banner, so the printed URL must be navigable.
|
|
127
|
+
const line = formatListeningBanner({ ...base, hostname: "0.0.0.0" });
|
|
128
|
+
expect(line).toContain("listening on http://localhost:1939");
|
|
129
|
+
expect(line).toContain("(bound on all interfaces: 0.0.0.0:1939)");
|
|
130
|
+
// The bind disclosure should sit between the URL and the contextual
|
|
131
|
+
// PARACHUTE_HOME / db / issuer block, so an operator scanning the
|
|
132
|
+
// banner sees the URL first and the bind note second.
|
|
133
|
+
const urlIdx = line.indexOf("http://localhost:1939");
|
|
134
|
+
const boundIdx = line.indexOf("(bound on all interfaces");
|
|
135
|
+
const homeIdx = line.indexOf("PARACHUTE_HOME=");
|
|
136
|
+
expect(urlIdx).toBeLessThan(boundIdx);
|
|
137
|
+
expect(boundIdx).toBeLessThan(homeIdx);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("hostname 127.0.0.1 (operator-chosen loopback) → display verbatim, no bound note", () => {
|
|
141
|
+
const line = formatListeningBanner({ ...base, hostname: "127.0.0.1" });
|
|
142
|
+
expect(line).toContain("listening on http://127.0.0.1:1939");
|
|
143
|
+
expect(line).not.toContain("bound on all interfaces");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("hostname 192.168.x.x (operator-chosen LAN IP) → display verbatim, no bound note", () => {
|
|
147
|
+
const line = formatListeningBanner({ ...base, hostname: "192.168.1.10" });
|
|
148
|
+
expect(line).toContain("listening on http://192.168.1.10:1939");
|
|
149
|
+
expect(line).not.toContain("bound on all interfaces");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("contextual block carries PARACHUTE_HOME, db, issuer, admin state", () => {
|
|
153
|
+
const line = formatListeningBanner({
|
|
154
|
+
...base,
|
|
155
|
+
hostname: "0.0.0.0",
|
|
156
|
+
issuer: "https://hub.example.com",
|
|
157
|
+
adminBootstrap: "seeded",
|
|
158
|
+
});
|
|
159
|
+
expect(line).toContain("PARACHUTE_HOME=/home/op/.parachute");
|
|
160
|
+
expect(line).toContain("db=/home/op/.parachute/hub.db");
|
|
161
|
+
expect(line).toContain("issuer=https://hub.example.com");
|
|
162
|
+
expect(line).toContain("admin=seeded");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("issuer undefined renders as <request-origin> placeholder", () => {
|
|
166
|
+
const line = formatListeningBanner({ ...base, hostname: "0.0.0.0" });
|
|
167
|
+
expect(line).toContain("issuer=<request-origin>");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- bootstrap-token banner (first-boot-path hardening, Issue 1) ---------
|
|
172
|
+
|
|
173
|
+
describe("formatBootstrapTokenBanner", () => {
|
|
174
|
+
test("includes the token verbatim, the wizard URL, and an expiry note", () => {
|
|
175
|
+
const banner = formatBootstrapTokenBanner("parachute-bootstrap-sample-token-abc-123");
|
|
176
|
+
expect(banner).toContain("parachute-bootstrap-sample-token-abc-123");
|
|
177
|
+
expect(banner).toContain("/admin/setup");
|
|
178
|
+
expect(banner).toContain("admin is created OR when hub restarts");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("each line carries the `[wizard]` prefix so a log scanner can isolate the block", () => {
|
|
182
|
+
const banner = formatBootstrapTokenBanner("parachute-bootstrap-x");
|
|
183
|
+
for (const line of banner.split("\n")) {
|
|
184
|
+
expect(line.startsWith("[wizard]")).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// --- bootstrap-token generation under needs-setup (Issue 1 wiring) -------
|
|
190
|
+
//
|
|
191
|
+
// `seedInitialAdminIfNeeded` returns `needs-setup` when no admin row and
|
|
192
|
+
// no env vars; the wizard-mode boot path then mints + logs the token.
|
|
193
|
+
// These tests pin the wiring: when `needs-setup` fires, the token slot
|
|
194
|
+
// gets populated; when the env-seed path fires, the token slot stays
|
|
195
|
+
// undefined.
|
|
196
|
+
|
|
197
|
+
describe("bootstrap-token wiring under needs-setup", () => {
|
|
198
|
+
let dir: string;
|
|
199
|
+
let dbPath: string;
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
dir = mkdtempSync(join(tmpdir(), "parachute-serve-bootstrap-"));
|
|
203
|
+
dbPath = join(dir, "hub.db");
|
|
204
|
+
_resetBootstrapTokenForTests();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
rmSync(dir, { recursive: true, force: true });
|
|
209
|
+
_resetBootstrapTokenForTests();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("needs-setup branch: seedInitialAdminIfNeeded itself does NOT mint a token", async () => {
|
|
213
|
+
// The token mint is in serve() — the surrounding fetch loop — not
|
|
214
|
+
// in `seedInitialAdminIfNeeded`. This pins the helper's contract:
|
|
215
|
+
// pure state inspection, no side effects beyond the admin row.
|
|
216
|
+
// (The mint happens in the caller; covered by integration via the
|
|
217
|
+
// hub-side gate tests that exercise the wizard against a generated
|
|
218
|
+
// token.)
|
|
219
|
+
const db = openHubDb(dbPath);
|
|
220
|
+
const result = await seedInitialAdminIfNeeded(db, {}, () => {});
|
|
221
|
+
expect(result).toBe("needs-setup");
|
|
222
|
+
expect(getBootstrapToken()).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("env-seed branch: no token generated by the seed helper", async () => {
|
|
226
|
+
const db = openHubDb(dbPath);
|
|
227
|
+
const result = await seedInitialAdminIfNeeded(
|
|
228
|
+
db,
|
|
229
|
+
{
|
|
230
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
231
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
|
|
232
|
+
},
|
|
233
|
+
() => {},
|
|
234
|
+
);
|
|
235
|
+
expect(result).toBe("seeded");
|
|
236
|
+
expect(getBootstrapToken()).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -219,4 +219,97 @@ describe("setup gate (admin exists)", () => {
|
|
|
219
219
|
db.close();
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
// Issue 2 (first-boot-path hardening): the auto-redirect on `/` and
|
|
224
|
+
// `/hub.html` fires whenever the wizard still has work to do — not just
|
|
225
|
+
// when the admin row is missing. Pre-fix, an env-seeded admin with no
|
|
226
|
+
// vault landed on the static discovery portal and had to hand-find
|
|
227
|
+
// `/admin/modules` + `/admin/vaults`. Post-fix, `/` funnels them
|
|
228
|
+
// straight to the wizard's vault step.
|
|
229
|
+
test("/ 302s to /admin/setup when env-seeded admin has no vault (Issue 2)", async () => {
|
|
230
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
231
|
+
try {
|
|
232
|
+
// Simulate env-seed: admin row exists, services.json is empty.
|
|
233
|
+
await createUser(db, "env-seeded-admin", "pw");
|
|
234
|
+
const res = await hubFetch(h.dir, {
|
|
235
|
+
getDb: () => db,
|
|
236
|
+
manifestPath: join(h.dir, "services.json"),
|
|
237
|
+
})(req("/"));
|
|
238
|
+
expect(res.status).toBe(302);
|
|
239
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
240
|
+
} finally {
|
|
241
|
+
db.close();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("/hub.html 302s to /admin/setup when env-seeded admin has no vault (Issue 2)", async () => {
|
|
246
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
247
|
+
try {
|
|
248
|
+
await createUser(db, "env-seeded-admin", "pw");
|
|
249
|
+
const res = await hubFetch(h.dir, {
|
|
250
|
+
getDb: () => db,
|
|
251
|
+
manifestPath: join(h.dir, "services.json"),
|
|
252
|
+
})(req("/hub.html"));
|
|
253
|
+
expect(res.status).toBe(302);
|
|
254
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
255
|
+
} finally {
|
|
256
|
+
db.close();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("/ renders the discovery page when admin + vault both exist", async () => {
|
|
261
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
262
|
+
try {
|
|
263
|
+
await createUser(db, "owner", "pw");
|
|
264
|
+
writeManifest(
|
|
265
|
+
{
|
|
266
|
+
services: [
|
|
267
|
+
{
|
|
268
|
+
name: "parachute-vault",
|
|
269
|
+
version: "0.1.0",
|
|
270
|
+
port: 1940,
|
|
271
|
+
paths: ["/vault/default"],
|
|
272
|
+
health: "/health",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
join(h.dir, "services.json"),
|
|
277
|
+
);
|
|
278
|
+
const res = await hubFetch(h.dir, {
|
|
279
|
+
getDb: () => db,
|
|
280
|
+
manifestPath: join(h.dir, "services.json"),
|
|
281
|
+
})(req("/"));
|
|
282
|
+
// 200 (the dynamic discovery page) — NOT 302 to /admin/setup. The
|
|
283
|
+
// wizard's work is done.
|
|
284
|
+
expect(res.status).toBe(200);
|
|
285
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
286
|
+
} finally {
|
|
287
|
+
db.close();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("wizard at /admin/setup with env-seeded admin + no vault renders vault step (Issue 2)", async () => {
|
|
292
|
+
// Mirror the wizard's resume-at-vault-step shape for the env-seed
|
|
293
|
+
// path. Same as the existing test above, but explicitly named to
|
|
294
|
+
// document the Issue 2 expectation: the wizard handles env-seeded
|
|
295
|
+
// admins correctly.
|
|
296
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
297
|
+
try {
|
|
298
|
+
await createUser(db, "env-seeded-admin", "pw");
|
|
299
|
+
const res = await hubFetch(h.dir, {
|
|
300
|
+
getDb: () => db,
|
|
301
|
+
manifestPath: join(h.dir, "services.json"),
|
|
302
|
+
})(req("/admin/setup"));
|
|
303
|
+
expect(res.status).toBe(200);
|
|
304
|
+
const html = await res.text();
|
|
305
|
+
// Vault step is rendered (the form action gives it away).
|
|
306
|
+
expect(html).toContain('action="/admin/setup/vault"');
|
|
307
|
+
// Account step is NOT rendered — no username field, no bootstrap
|
|
308
|
+
// token field.
|
|
309
|
+
expect(html).not.toContain('name="bootstrap_token"');
|
|
310
|
+
expect(html).not.toContain('name="password_confirm"');
|
|
311
|
+
} finally {
|
|
312
|
+
db.close();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
222
315
|
});
|