@openparachute/hub 0.6.4-rc.4 → 0.6.4-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +182 -109
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/api-account.test.ts +51 -1
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-cloudflare.test.ts +27 -5
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/init.test.ts +42 -3
- package/src/__tests__/install.test.ts +24 -1
- package/src/account-home-ui.ts +176 -254
- package/src/account-mirror.ts +126 -0
- package/src/api-account.ts +49 -0
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-cloudflare.ts +75 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/init.ts +42 -2
- package/src/commands/install.ts +36 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.4-rc.
|
|
3
|
+
"version": "0.6.4-rc.6",
|
|
4
4
|
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"format": "biome format --write .",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
35
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
36
35
|
"prepack": "bun run build:spa"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* tests pin the load-bearing shape:
|
|
5
5
|
*
|
|
6
6
|
* - Assigned-vault branch: Notes CTA href encodes the hub+vault URL,
|
|
7
|
-
* vault name shows in the body,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* vault name shows in the body, the backup-state line surfaces, and a
|
|
8
|
+
* "build your own UI" hint links the surface-build starter. The connect
|
|
9
|
+
* instructions live ONCE in the onboarding checklist (not duplicated in
|
|
10
|
+
* the vault card); token-minting is gone from /account (OAuth-first);
|
|
11
|
+
* a single "Advanced vault settings ↗" deep-link covers advanced needs.
|
|
10
12
|
* - Admin (no assigned vault) branch: link to /admin/ visible.
|
|
11
13
|
* - Defensive third branch (non-admin + no vault): "ask the operator"
|
|
12
14
|
* copy renders.
|
|
@@ -44,35 +46,9 @@ describe("renderAccountHome", () => {
|
|
|
44
46
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${encodedVaultUrl}`);
|
|
45
47
|
expect(html).toContain('target="_blank"');
|
|
46
48
|
expect(html).toContain('rel="noopener"');
|
|
47
|
-
// The friend-connect surface: MCP endpoint + `claude mcp add` command,
|
|
48
|
-
// each with a copy button. OAuth path (no token in the command).
|
|
49
|
-
expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
50
|
-
expect(html).toContain(
|
|
51
|
-
`claude mcp add --transport http parachute-alice ${HUB_ORIGIN}/vault/alice/mcp`,
|
|
52
|
-
);
|
|
53
|
-
expect(html).toContain('data-testid="copy-mcp-endpoint"');
|
|
54
|
-
expect(html).toContain('data-testid="copy-mcp-add-command"');
|
|
55
|
-
// The connect command must NOT embed a token — the OAuth path needs none.
|
|
56
|
-
expect(html).not.toContain("--header");
|
|
57
|
-
expect(html).not.toContain("Authorization: Bearer");
|
|
58
49
|
// Copy-button progressive-enhancement script is present.
|
|
59
50
|
expect(html).toContain("navigator.clipboard");
|
|
60
|
-
//
|
|
61
|
-
// rather than MCP jargon up top.
|
|
62
|
-
expect(html).toContain('data-testid="connect-ai-heading"');
|
|
63
|
-
expect(html).toContain("Connect your AI");
|
|
64
|
-
// BOTH connect methods render as distinct, labelled blocks.
|
|
65
|
-
expect(html).toContain('data-testid="connect-method-claude-code"');
|
|
66
|
-
expect(html).toContain("Claude Code");
|
|
67
|
-
expect(html).toContain('data-testid="connect-method-claude-ai"');
|
|
68
|
-
expect(html).toContain("Claude.ai");
|
|
69
|
-
// The Claude.ai path mirrors the install.njk canonical phrasing
|
|
70
|
-
// (Settings → Connectors → Add custom connector, paste the endpoint).
|
|
71
|
-
expect(html).toContain("Connectors");
|
|
72
|
-
expect(html).toContain("Add custom connector");
|
|
73
|
-
// A brief "any other MCP client" line is present (no bloat — just one).
|
|
74
|
-
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
|
-
// Notes CTA still present, now framed as the browser-UI option.
|
|
51
|
+
// Notes CTA present, framed as the browser-UI option.
|
|
76
52
|
expect(html).toContain('data-testid="open-notes-cta"');
|
|
77
53
|
// Import-notes CTA deep-links to the Notes-UI /import route for the same
|
|
78
54
|
// vault, mirroring the Open-Notes target-resolution (same hosted origin,
|
|
@@ -81,6 +57,78 @@ describe("renderAccountHome", () => {
|
|
|
81
57
|
expect(html).toContain('data-testid="import-notes-cta"');
|
|
82
58
|
});
|
|
83
59
|
|
|
60
|
+
test("dedup — the full connect block lives ONCE (in the checklist), NOT in the vault card", () => {
|
|
61
|
+
const html = renderAccountHome({
|
|
62
|
+
username: "alice",
|
|
63
|
+
assignedVaults: ["alice"],
|
|
64
|
+
passwordChanged: true,
|
|
65
|
+
hubOrigin: HUB_ORIGIN,
|
|
66
|
+
isFirstAdmin: false,
|
|
67
|
+
csrfToken: CSRF,
|
|
68
|
+
twoFactorEnabled: false,
|
|
69
|
+
connectedVault: false,
|
|
70
|
+
});
|
|
71
|
+
// The checklist owns "Connect your AI" — the endpoint + the `claude mcp add`
|
|
72
|
+
// command (OAuth, no token) appear there.
|
|
73
|
+
expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
|
|
74
|
+
expect(html).toContain('data-testid="onboarding-mcp-add-command"');
|
|
75
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
76
|
+
// The vault card must NOT repeat the connect instructions. None of the
|
|
77
|
+
// old mcp-connect block markers may appear — no duplicated connect surface.
|
|
78
|
+
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
79
|
+
expect(html).not.toContain('data-testid="connect-ai-heading"');
|
|
80
|
+
expect(html).not.toContain('data-testid="connect-method-claude-code"');
|
|
81
|
+
expect(html).not.toContain('data-testid="connect-method-claude-ai"');
|
|
82
|
+
expect(html).not.toContain('data-testid="connect-any-client-hint"');
|
|
83
|
+
// The connect instructions live only in the checklist, never duplicated in
|
|
84
|
+
// the vault card: split the page at the vault card and assert the endpoint
|
|
85
|
+
// doesn't reappear in that slice.
|
|
86
|
+
const cardIdx = html.indexOf('data-testid="vault-card"');
|
|
87
|
+
expect(cardIdx).toBeGreaterThan(-1);
|
|
88
|
+
const vaultCardSlice = html.slice(cardIdx);
|
|
89
|
+
expect(vaultCardSlice).not.toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
90
|
+
expect(vaultCardSlice).not.toContain("claude mcp add");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("token-mint — the mint affordance is gone from /account (OAuth-first)", () => {
|
|
94
|
+
const html = renderAccountHome({
|
|
95
|
+
username: "alice",
|
|
96
|
+
assignedVaults: ["alice"],
|
|
97
|
+
passwordChanged: true,
|
|
98
|
+
hubOrigin: HUB_ORIGIN,
|
|
99
|
+
isFirstAdmin: false,
|
|
100
|
+
csrfToken: CSRF,
|
|
101
|
+
twoFactorEnabled: false,
|
|
102
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
103
|
+
});
|
|
104
|
+
// No token-mint <details> block, no mint form, no verb radios — minting a
|
|
105
|
+
// header-auth token is a script/advanced concern that lives in the SPA.
|
|
106
|
+
expect(html).not.toContain('data-testid="token-mint"');
|
|
107
|
+
expect(html).not.toContain('data-testid="mint-form"');
|
|
108
|
+
expect(html).not.toContain('data-testid="mint-verb-read"');
|
|
109
|
+
expect(html).not.toContain('data-testid="mint-verb-admin"');
|
|
110
|
+
expect(html).not.toContain("Mint an access token");
|
|
111
|
+
// The single advanced entry point covers the advanced needs.
|
|
112
|
+
expect(html).toContain('data-testid="vault-admin-button"');
|
|
113
|
+
expect(html).toContain("Advanced vault settings");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("build-your-own-UI — Notes is one way; the hint links the surface-build starter", () => {
|
|
117
|
+
const html = renderAccountHome({
|
|
118
|
+
username: "alice",
|
|
119
|
+
assignedVaults: ["alice"],
|
|
120
|
+
passwordChanged: true,
|
|
121
|
+
hubOrigin: HUB_ORIGIN,
|
|
122
|
+
isFirstAdmin: false,
|
|
123
|
+
csrfToken: CSRF,
|
|
124
|
+
twoFactorEnabled: false,
|
|
125
|
+
});
|
|
126
|
+
expect(html).toContain('data-testid="build-your-own-ui"');
|
|
127
|
+
expect(html).toContain('data-testid="build-your-own-ui-link"');
|
|
128
|
+
expect(html).toContain("https://parachute.computer/onboarding/surface-build/");
|
|
129
|
+
expect(html).toContain("build you a custom UI");
|
|
130
|
+
});
|
|
131
|
+
|
|
84
132
|
test("assigned-vault branch — import-notes CTA gated alongside open-notes (no dead link)", () => {
|
|
85
133
|
// Both CTAs render together for an assigned vault and are absent together
|
|
86
134
|
// in the no-vault branches — so we never surface an Import link that points
|
|
@@ -233,8 +281,46 @@ describe("renderAccountHome", () => {
|
|
|
233
281
|
expect(noVault).toContain('data-testid="no-vault-card"');
|
|
234
282
|
});
|
|
235
283
|
|
|
236
|
-
test("
|
|
237
|
-
const
|
|
284
|
+
test("backup state — renders the backup line + omits when no mirror entry", () => {
|
|
285
|
+
const backedUp = renderAccountHome({
|
|
286
|
+
username: "alice",
|
|
287
|
+
assignedVaults: ["alice"],
|
|
288
|
+
passwordChanged: true,
|
|
289
|
+
hubOrigin: HUB_ORIGIN,
|
|
290
|
+
isFirstAdmin: false,
|
|
291
|
+
csrfToken: CSRF,
|
|
292
|
+
twoFactorEnabled: false,
|
|
293
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
294
|
+
mirrorLines: { alice: "Backed up — full version history" },
|
|
295
|
+
});
|
|
296
|
+
// The warm, plain-language backup line surfaces on the tile.
|
|
297
|
+
expect(backedUp).toContain('data-testid="backup-state-line"');
|
|
298
|
+
expect(backedUp).toContain("Backed up — full version history");
|
|
299
|
+
// Not pushing yet → a "Back up to GitHub ↗" action (reuses the
|
|
300
|
+
// vault-admin-token deep-link, gated on admin).
|
|
301
|
+
expect(backedUp).toContain('data-testid="backup-github-button"');
|
|
302
|
+
expect(backedUp).toContain("Back up to GitHub");
|
|
303
|
+
expect(backedUp).toContain("/account/vault-admin-token/alice");
|
|
304
|
+
|
|
305
|
+
// GitHub variant: when pushing, the line says so and the action is dropped.
|
|
306
|
+
// Suppression is gated on the `mirrorPushing` boolean (NOT the line string).
|
|
307
|
+
const pushing = renderAccountHome({
|
|
308
|
+
username: "alice",
|
|
309
|
+
assignedVaults: ["alice"],
|
|
310
|
+
passwordChanged: true,
|
|
311
|
+
hubOrigin: HUB_ORIGIN,
|
|
312
|
+
isFirstAdmin: false,
|
|
313
|
+
csrfToken: CSRF,
|
|
314
|
+
twoFactorEnabled: false,
|
|
315
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
316
|
+
mirrorLines: { alice: "Backed up — version history + GitHub" },
|
|
317
|
+
mirrorPushing: { alice: true },
|
|
318
|
+
});
|
|
319
|
+
expect(pushing).toContain("version history + GitHub");
|
|
320
|
+
expect(pushing).not.toContain('data-testid="backup-github-button"');
|
|
321
|
+
|
|
322
|
+
// Omitted silently when the mirror fetch returned nothing (no entry).
|
|
323
|
+
const noMirror = renderAccountHome({
|
|
238
324
|
username: "alice",
|
|
239
325
|
assignedVaults: ["alice"],
|
|
240
326
|
passwordChanged: true,
|
|
@@ -242,14 +328,43 @@ describe("renderAccountHome", () => {
|
|
|
242
328
|
isFirstAdmin: false,
|
|
243
329
|
csrfToken: CSRF,
|
|
244
330
|
twoFactorEnabled: false,
|
|
331
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
245
332
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
333
|
+
expect(noMirror).not.toContain('data-testid="backup-state-line"');
|
|
334
|
+
expect(noMirror).not.toContain('data-testid="vault-backup"');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("backup action — gated on the mirrorPushing boolean, not the line string", () => {
|
|
338
|
+
// Regression guard: the "Back up to GitHub ↗" suppression must follow the
|
|
339
|
+
// proper `mirrorPushing` boolean, NOT substring-match the display line.
|
|
340
|
+
// (a) line mentions "GitHub" but mirrorPushing is false → action STILL shows.
|
|
341
|
+
const lineLies = renderAccountHome({
|
|
342
|
+
username: "alice",
|
|
343
|
+
assignedVaults: ["alice"],
|
|
344
|
+
passwordChanged: true,
|
|
345
|
+
hubOrigin: HUB_ORIGIN,
|
|
346
|
+
isFirstAdmin: false,
|
|
347
|
+
csrfToken: CSRF,
|
|
348
|
+
twoFactorEnabled: false,
|
|
349
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
350
|
+
mirrorLines: { alice: "Backed up — full version history (GitHub disabled)" },
|
|
351
|
+
mirrorPushing: { alice: false },
|
|
352
|
+
});
|
|
353
|
+
expect(lineLies).toContain('data-testid="backup-github-button"');
|
|
354
|
+
// (b) mirrorPushing true but the line lacks "GitHub" → action suppressed.
|
|
355
|
+
const boolWins = renderAccountHome({
|
|
356
|
+
username: "alice",
|
|
357
|
+
assignedVaults: ["alice"],
|
|
358
|
+
passwordChanged: true,
|
|
359
|
+
hubOrigin: HUB_ORIGIN,
|
|
360
|
+
isFirstAdmin: false,
|
|
361
|
+
csrfToken: CSRF,
|
|
362
|
+
twoFactorEnabled: false,
|
|
363
|
+
mintableVerbs: { alice: ["read", "write", "admin"] },
|
|
364
|
+
mirrorLines: { alice: "Backed up — full version history" },
|
|
365
|
+
mirrorPushing: { alice: true },
|
|
366
|
+
});
|
|
367
|
+
expect(boolWins).not.toContain('data-testid="backup-github-button"');
|
|
253
368
|
});
|
|
254
369
|
|
|
255
370
|
test("account card — security actions collapse into a secondary <details>", () => {
|
|
@@ -347,18 +462,13 @@ describe("renderAccountHome", () => {
|
|
|
347
462
|
const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
|
|
348
463
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
|
|
349
464
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
|
|
350
|
-
//
|
|
351
|
-
expect(html).
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
`claude mcp add --transport http parachute-family ${HUB_ORIGIN}/vault/family/mcp`,
|
|
358
|
-
);
|
|
359
|
-
// Two tiles → two copy-endpoint buttons.
|
|
360
|
-
expect(html.split('data-testid="copy-mcp-endpoint"').length - 1).toBe(2);
|
|
361
|
-
// The copy script is emitted once at the section level, not per-tile.
|
|
465
|
+
// Two tiles → two Open-Notes CTAs.
|
|
466
|
+
expect(html.split('data-testid="open-notes-cta"').length - 1).toBe(2);
|
|
467
|
+
// The vault card does NOT repeat the connect block — that lives in the
|
|
468
|
+
// checklist (which uses the first/primary vault only).
|
|
469
|
+
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
470
|
+
expect(html).not.toContain(`${HUB_ORIGIN}/vault/family/mcp`);
|
|
471
|
+
// The copy script is emitted once at the page level, not per-tile.
|
|
362
472
|
expect(html.split("<script>").length - 1).toBe(1);
|
|
363
473
|
});
|
|
364
474
|
|
|
@@ -396,9 +506,9 @@ describe("renderAccountHome", () => {
|
|
|
396
506
|
expect(html).toContain("parachute-<vault>");
|
|
397
507
|
});
|
|
398
508
|
|
|
399
|
-
// ---
|
|
509
|
+
// --- single advanced entry point (gated on the admin verb) ---------------
|
|
400
510
|
|
|
401
|
-
test("
|
|
511
|
+
test("advanced settings link — present + gated on the admin verb", () => {
|
|
402
512
|
const html = renderAccountHome({
|
|
403
513
|
username: "alice",
|
|
404
514
|
assignedVaults: ["work"],
|
|
@@ -407,45 +517,22 @@ describe("renderAccountHome", () => {
|
|
|
407
517
|
isFirstAdmin: false,
|
|
408
518
|
csrfToken: CSRF,
|
|
409
519
|
twoFactorEnabled: false,
|
|
410
|
-
mintableVerbs: { work: ["read", "write"] },
|
|
520
|
+
mintableVerbs: { work: ["read", "write", "admin"] },
|
|
411
521
|
});
|
|
412
|
-
//
|
|
413
|
-
expect(html).toContain('data-testid="
|
|
414
|
-
expect(html).toContain("
|
|
415
|
-
expect(html).toContain("
|
|
416
|
-
|
|
417
|
-
expect(html).toContain('data-testid="mint-verb-read"');
|
|
418
|
-
expect(html).toContain('data-testid="mint-verb-write"');
|
|
419
|
-
// Form POSTs to the per-vault endpoint with the CSRF token embedded.
|
|
420
|
-
expect(html).toContain('action="/account/vault-token/work"');
|
|
522
|
+
// One clearly-labelled "Advanced vault settings ↗" link → the SPA deep-link.
|
|
523
|
+
expect(html).toContain('data-testid="vault-admin-form"');
|
|
524
|
+
expect(html).toContain('data-testid="vault-admin-button"');
|
|
525
|
+
expect(html).toContain("Advanced vault settings");
|
|
526
|
+
expect(html).toContain('action="/account/vault-admin-token/work"');
|
|
421
527
|
expect(html).toContain('method="POST"');
|
|
422
|
-
expect(html).toContain('data-testid="mint-form"');
|
|
423
528
|
expect(html).toContain(CSRF);
|
|
424
|
-
//
|
|
425
|
-
expect(html).toContain("
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
test("mint affordance — a read-only role offers ONLY the read verb", () => {
|
|
429
|
-
// Today every assignment is write-role, but the renderer is verb-blind to
|
|
430
|
-
// the role: it shows exactly the verbs it's handed. A read-only cap must
|
|
431
|
-
// never surface a write radio (the server would reject it anyway).
|
|
432
|
-
const html = renderAccountHome({
|
|
433
|
-
username: "alice",
|
|
434
|
-
assignedVaults: ["work"],
|
|
435
|
-
passwordChanged: true,
|
|
436
|
-
hubOrigin: HUB_ORIGIN,
|
|
437
|
-
isFirstAdmin: false,
|
|
438
|
-
csrfToken: CSRF,
|
|
439
|
-
twoFactorEnabled: false,
|
|
440
|
-
mintableVerbs: { work: ["read"] },
|
|
441
|
-
});
|
|
442
|
-
expect(html).toContain('data-testid="mint-verb-read"');
|
|
443
|
-
expect(html).not.toContain('data-testid="mint-verb-write"');
|
|
529
|
+
// The old dual-purpose "Configure / back up this vault" label is gone.
|
|
530
|
+
expect(html).not.toContain("Configure / back up this vault");
|
|
444
531
|
});
|
|
445
532
|
|
|
446
|
-
test("
|
|
447
|
-
//
|
|
448
|
-
//
|
|
533
|
+
test("advanced settings link — absent when the user lacks the admin verb", () => {
|
|
534
|
+
// A read/write-only assignment must not surface the admin deep-link (the
|
|
535
|
+
// POST handler would 403 the admin token mint).
|
|
449
536
|
const html = renderAccountHome({
|
|
450
537
|
username: "alice",
|
|
451
538
|
assignedVaults: ["work"],
|
|
@@ -454,14 +541,10 @@ describe("renderAccountHome", () => {
|
|
|
454
541
|
isFirstAdmin: false,
|
|
455
542
|
csrfToken: CSRF,
|
|
456
543
|
twoFactorEnabled: false,
|
|
457
|
-
mintableVerbs: { work: ["read", "write"
|
|
544
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
458
545
|
});
|
|
459
|
-
expect(html).toContain('
|
|
460
|
-
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
|
|
464
|
-
// Admin branch: no tiles at all, so no mint block.
|
|
546
|
+
expect(html).not.toContain('data-testid="vault-admin-button"');
|
|
547
|
+
// Admin branch: no tiles at all, so no advanced link.
|
|
465
548
|
const admin = renderAccountHome({
|
|
466
549
|
username: "admin",
|
|
467
550
|
assignedVaults: [],
|
|
@@ -471,19 +554,7 @@ describe("renderAccountHome", () => {
|
|
|
471
554
|
csrfToken: CSRF,
|
|
472
555
|
twoFactorEnabled: false,
|
|
473
556
|
});
|
|
474
|
-
expect(admin).not.toContain('data-testid="
|
|
475
|
-
// Assigned vault but empty verb list (fail-closed unknown role) → no block.
|
|
476
|
-
const empty = renderAccountHome({
|
|
477
|
-
username: "alice",
|
|
478
|
-
assignedVaults: ["work"],
|
|
479
|
-
passwordChanged: true,
|
|
480
|
-
hubOrigin: HUB_ORIGIN,
|
|
481
|
-
isFirstAdmin: false,
|
|
482
|
-
csrfToken: CSRF,
|
|
483
|
-
twoFactorEnabled: false,
|
|
484
|
-
mintableVerbs: { work: [] },
|
|
485
|
-
});
|
|
486
|
-
expect(empty).not.toContain('data-testid="token-mint"');
|
|
557
|
+
expect(admin).not.toContain('data-testid="vault-admin-button"');
|
|
487
558
|
});
|
|
488
559
|
|
|
489
560
|
test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
|
|
@@ -645,8 +716,10 @@ describe("renderAccountHome", () => {
|
|
|
645
716
|
connectedVault: false,
|
|
646
717
|
});
|
|
647
718
|
// The checklist's connect step references the first/primary vault; the
|
|
648
|
-
// per-vault tiles below still list every vault
|
|
719
|
+
// per-vault tiles below still list every vault (by name + Notes CTA), but
|
|
720
|
+
// no longer repeat the connect endpoint (dedup — connect lives only here).
|
|
649
721
|
expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/personal\/mcp</);
|
|
650
|
-
expect(html).toContain(
|
|
722
|
+
expect(html).toContain("<strong>family</strong>"); // second vault still has a tile
|
|
723
|
+
expect(html).not.toContain(`${HUB_ORIGIN}/vault/family/mcp`); // no duplicated connect
|
|
651
724
|
});
|
|
652
725
|
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `/account/` per-vault backup (mirror) status fetch +
|
|
3
|
+
* formatting (`account-mirror.ts`). The fetch mints an admin-scoped token + hits
|
|
4
|
+
* the vault's loopback `/.parachute/mirror` endpoint; it must be fault-tolerant
|
|
5
|
+
* (any failure → null) and shape-strict (a malformed body → null, not a render
|
|
6
|
+
* of `undefined`). Mirrors `account-usage.test.ts`'s posture.
|
|
7
|
+
*/
|
|
8
|
+
import type { Database } from "bun:sqlite";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
type VaultMirrorStat,
|
|
15
|
+
fetchVaultMirrorStatus,
|
|
16
|
+
formatMirrorLine,
|
|
17
|
+
} from "../account-mirror.ts";
|
|
18
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
19
|
+
|
|
20
|
+
interface Harness {
|
|
21
|
+
db: Database;
|
|
22
|
+
cleanup: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeHarness(): Harness {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-mirror-"));
|
|
27
|
+
const db = openHubDb(hubDbPath(dir));
|
|
28
|
+
return {
|
|
29
|
+
db,
|
|
30
|
+
cleanup: () => {
|
|
31
|
+
db.close();
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let harness: Harness;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
harness = makeHarness();
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
harness.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A stub signer — no real key needed; the fetch only carries the token string. */
|
|
46
|
+
const stubSign = async () => ({
|
|
47
|
+
token: "stub.jwt.token",
|
|
48
|
+
jti: "jti-1",
|
|
49
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function baseDeps(fetchImpl: typeof fetch) {
|
|
53
|
+
return {
|
|
54
|
+
db: harness.db,
|
|
55
|
+
hubOrigin: "https://hub.test",
|
|
56
|
+
vaultPort: 1940,
|
|
57
|
+
userId: "user-1",
|
|
58
|
+
fetchImpl,
|
|
59
|
+
signToken: stubSign as never,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("fetchVaultMirrorStatus", () => {
|
|
64
|
+
test("returns enabled+not-pushing on a backed-up, local-only config", async () => {
|
|
65
|
+
const fetchImpl = (async () =>
|
|
66
|
+
new Response(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
config: { enabled: true, location: "internal", auto_push: false },
|
|
69
|
+
status: { enabled: true, last_commit_sha: "abc", last_error: null },
|
|
70
|
+
}),
|
|
71
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
72
|
+
)) as unknown as typeof fetch;
|
|
73
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
74
|
+
expect(stat).toEqual({ enabled: true, backedUpToRemote: false });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("flags pushing when auto_push is configured", async () => {
|
|
78
|
+
const fetchImpl = (async () =>
|
|
79
|
+
new Response(
|
|
80
|
+
JSON.stringify({ config: { enabled: true, location: "internal", auto_push: true } }),
|
|
81
|
+
{ status: 200 },
|
|
82
|
+
)) as unknown as typeof fetch;
|
|
83
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
84
|
+
expect(stat).toEqual({ enabled: true, backedUpToRemote: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns enabled:false when backup is off", async () => {
|
|
88
|
+
const fetchImpl = (async () =>
|
|
89
|
+
new Response(JSON.stringify({ config: { enabled: false, auto_push: false } }), {
|
|
90
|
+
status: 200,
|
|
91
|
+
})) as unknown as typeof fetch;
|
|
92
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
93
|
+
expect(stat).toEqual({ enabled: false, backedUpToRemote: false });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("mints an ADMIN-scoped Bearer + hits the vault's loopback mirror endpoint", async () => {
|
|
97
|
+
let seenUrl = "";
|
|
98
|
+
let seenAuth = "";
|
|
99
|
+
let seenScope: string[] = [];
|
|
100
|
+
const captureSign = (async (_db: unknown, opts: { scopes: string[] }) => {
|
|
101
|
+
seenScope = opts.scopes;
|
|
102
|
+
return { token: "stub.jwt.token", jti: "j", expiresAt: new Date().toISOString() };
|
|
103
|
+
}) as never;
|
|
104
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
105
|
+
seenUrl = url;
|
|
106
|
+
seenAuth = (init?.headers as Record<string, string>)?.authorization ?? "";
|
|
107
|
+
return new Response(JSON.stringify({ config: { enabled: true, auto_push: false } }), {
|
|
108
|
+
status: 200,
|
|
109
|
+
});
|
|
110
|
+
}) as unknown as typeof fetch;
|
|
111
|
+
await fetchVaultMirrorStatus("work", { ...baseDeps(fetchImpl), signToken: captureSign });
|
|
112
|
+
expect(seenUrl).toBe("http://127.0.0.1:1940/vault/work/.parachute/mirror");
|
|
113
|
+
expect(seenAuth).toBe("Bearer stub.jwt.token");
|
|
114
|
+
expect(seenScope).toEqual(["vault:work:admin"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns null on a non-2xx response (vault down / 403 / 404)", async () => {
|
|
118
|
+
for (const status of [403, 404, 500]) {
|
|
119
|
+
const fetchImpl = (async () => new Response("nope", { status })) as unknown as typeof fetch;
|
|
120
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
121
|
+
expect(stat).toBeNull();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("returns null when the body is malformed (missing config.enabled)", async () => {
|
|
126
|
+
const fetchImpl = (async () =>
|
|
127
|
+
new Response(JSON.stringify({ config: {} }), { status: 200 })) as unknown as typeof fetch;
|
|
128
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
129
|
+
expect(stat).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns null when fetch throws (network error)", async () => {
|
|
133
|
+
const fetchImpl = (async () => {
|
|
134
|
+
throw new Error("ECONNREFUSED");
|
|
135
|
+
}) as unknown as typeof fetch;
|
|
136
|
+
const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
|
|
137
|
+
expect(stat).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("formatMirrorLine", () => {
|
|
142
|
+
test("warm plain-language line; GitHub variant when pushing", () => {
|
|
143
|
+
expect(formatMirrorLine({ enabled: true, backedUpToRemote: false } as VaultMirrorStat)).toBe(
|
|
144
|
+
"Backed up — full version history",
|
|
145
|
+
);
|
|
146
|
+
expect(formatMirrorLine({ enabled: true, backedUpToRemote: true } as VaultMirrorStat)).toBe(
|
|
147
|
+
"Backed up — version history + GitHub",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("returns null when backup is off (the tile omits the line, never nags)", () => {
|
|
152
|
+
expect(
|
|
153
|
+
formatMirrorLine({ enabled: false, backedUpToRemote: false } as VaultMirrorStat),
|
|
154
|
+
).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -922,7 +922,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
922
922
|
expect(html).not.toContain('data-testid="vault-usage"');
|
|
923
923
|
});
|
|
924
924
|
|
|
925
|
-
test("renders the '
|
|
925
|
+
test("renders the 'Advanced vault settings ↗' deep-link button for an assigned vault", async () => {
|
|
926
926
|
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
927
927
|
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
928
928
|
allowMulti: true,
|
|
@@ -936,6 +936,56 @@ describe("handleAccountHomeGet", () => {
|
|
|
936
936
|
expect(res.status).toBe(200);
|
|
937
937
|
const html = await res.text();
|
|
938
938
|
expect(html).toContain('data-testid="vault-admin-button"');
|
|
939
|
+
expect(html).toContain("Advanced vault settings");
|
|
939
940
|
expect(html).toContain('action="/account/vault-admin-token/alice"');
|
|
940
941
|
});
|
|
942
|
+
|
|
943
|
+
test("renders the backup-state line when the mirror status resolves enabled", async () => {
|
|
944
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
945
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
946
|
+
allowMulti: true,
|
|
947
|
+
passwordChanged: true,
|
|
948
|
+
assignedVaults: ["alice"],
|
|
949
|
+
});
|
|
950
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
951
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
952
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
953
|
+
const res = await handleAccountHomeGet(req, {
|
|
954
|
+
db: harness.db,
|
|
955
|
+
hubOrigin: HUB_ORIGIN,
|
|
956
|
+
resolveVaultPort: () => 1940,
|
|
957
|
+
// Stub the mirror fetch: resolves to a backed-up, GitHub-pushing config.
|
|
958
|
+
fetchMirror: async () => ({ enabled: true, backedUpToRemote: true }),
|
|
959
|
+
});
|
|
960
|
+
expect(res.status).toBe(200);
|
|
961
|
+
const html = await res.text();
|
|
962
|
+
expect(html).toContain('data-testid="backup-state-line"');
|
|
963
|
+
// Already pushing → the handler threads mirrorPushing=true, so the
|
|
964
|
+
// "Back up to GitHub ↗" action is suppressed.
|
|
965
|
+
expect(html).not.toContain('data-testid="backup-github-button"');
|
|
966
|
+
expect(html).toContain("version history + GitHub");
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test("omits the backup line gracefully when the mirror fetch fails (null)", async () => {
|
|
970
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
971
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
972
|
+
allowMulti: true,
|
|
973
|
+
passwordChanged: true,
|
|
974
|
+
assignedVaults: ["alice"],
|
|
975
|
+
});
|
|
976
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
977
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
978
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
979
|
+
const res = await handleAccountHomeGet(req, {
|
|
980
|
+
db: harness.db,
|
|
981
|
+
hubOrigin: HUB_ORIGIN,
|
|
982
|
+
resolveVaultPort: () => 1940,
|
|
983
|
+
fetchMirror: async () => null,
|
|
984
|
+
});
|
|
985
|
+
expect(res.status).toBe(200);
|
|
986
|
+
const html = await res.text();
|
|
987
|
+
// Tile still renders; just no backup line.
|
|
988
|
+
expect(html).toContain("<strong>alice</strong>");
|
|
989
|
+
expect(html).not.toContain('data-testid="backup-state-line"');
|
|
990
|
+
});
|
|
941
991
|
});
|