@openparachute/hub 0.5.13-rc.41 → 0.5.13-rc.43

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.41",
3
+ "version": "0.5.13-rc.43",
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": {
@@ -365,6 +365,157 @@ describe("GET /api/modules", () => {
365
365
  expect(vault?.management_url).toBe("/vault/default/admin");
366
366
  });
367
367
 
368
+ test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
369
+ // Audit caught 2026-05-25: app declares `managementUrl: "/app/admin/"`
370
+ // (full hub-origin path) and `paths: ["/app", "/.parachute"]`. The
371
+ // SPA's Services dropdown was navigating to `/app/app/admin/` (404)
372
+ // because api-modules unconditionally prepended the mount onto the
373
+ // candidate. Fix: detect already-mount-prefixed paths and pass through.
374
+ //
375
+ // Single-instance modules conventionally declare the full path; only
376
+ // multi-instance modules (vault) use the per-instance relative form.
377
+ writeManifest(h.manifestPath, [
378
+ {
379
+ name: "parachute-app",
380
+ port: 1946,
381
+ paths: ["/app", "/.parachute"],
382
+ health: "/app/healthz",
383
+ version: "0.2.0-rc.13",
384
+ installDir: "/install/dir/app",
385
+ },
386
+ ]);
387
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
388
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
389
+ db: h.db,
390
+ issuer: ISSUER,
391
+ manifestPath: h.manifestPath,
392
+ fetchLatestVersion: async () => null,
393
+ readModuleManifest: async (installDir) => {
394
+ if (installDir === "/install/dir/app") {
395
+ return {
396
+ name: "app",
397
+ manifestName: "parachute-app",
398
+ displayName: "App",
399
+ tagline: "",
400
+ port: 1946,
401
+ paths: ["/app", "/.parachute"],
402
+ health: "/app/healthz",
403
+ uiUrl: "/app/admin/",
404
+ managementUrl: "/app/admin/",
405
+ } as unknown as Awaited<
406
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
407
+ >;
408
+ }
409
+ return null;
410
+ },
411
+ });
412
+ expect(res.status).toBe(200);
413
+ const body = (await res.json()) as {
414
+ modules: Array<{ short: string; management_url: string | null }>;
415
+ };
416
+ const app = body.modules.find((m) => m.short === "app");
417
+ // Single `/app/`, not `/app/app/`.
418
+ expect(app?.management_url).toBe("/app/admin/");
419
+ });
420
+
421
+ test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
422
+ // The detection uses `tail.startsWith(\`${mount}/\`)` with the trailing
423
+ // slash specifically to avoid a false positive when a candidate
424
+ // path looks like a sibling name (e.g. `/app-foo/admin` shouldn't be
425
+ // treated as "already prefixed by /app"). Without the slash gate,
426
+ // a future module named `app-foo` would silently inherit the
427
+ // pass-through behavior and `/app` mount would skip its prepend.
428
+ // Tests the trailing-slash discriminator stays load-bearing.
429
+ writeManifest(h.manifestPath, [
430
+ {
431
+ name: "parachute-vault",
432
+ port: 1940,
433
+ paths: ["/app"], // mount is /app (using vault as a stand-in installable)
434
+ health: "/app/health",
435
+ version: "0.4.5",
436
+ installDir: "/install/dir/contrived",
437
+ },
438
+ ]);
439
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
440
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
441
+ db: h.db,
442
+ issuer: ISSUER,
443
+ manifestPath: h.manifestPath,
444
+ fetchLatestVersion: async () => null,
445
+ readModuleManifest: async (installDir) => {
446
+ if (installDir === "/install/dir/contrived") {
447
+ return {
448
+ name: "parachute-vault",
449
+ manifestName: "parachute-vault",
450
+ displayName: "Vault",
451
+ tagline: "",
452
+ port: 1940,
453
+ paths: ["/app"],
454
+ health: "/app/health",
455
+ // candidate looks like a sibling-name prefix but is NOT a
456
+ // mount-prefix of /app — should still get prepended.
457
+ managementUrl: "/app-foo/admin",
458
+ } as unknown as Awaited<
459
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
460
+ >;
461
+ }
462
+ return null;
463
+ },
464
+ });
465
+ expect(res.status).toBe(200);
466
+ const body = (await res.json()) as {
467
+ modules: Array<{ short: string; management_url: string | null }>;
468
+ };
469
+ const vault = body.modules.find((m) => m.short === "vault");
470
+ // /app + /app-foo/admin → /app/app-foo/admin (prepend fires; not treated
471
+ // as already-mount-prefixed because /app-foo/ doesn't start with /app/).
472
+ expect(vault?.management_url).toBe("/app/app-foo/admin");
473
+ });
474
+
475
+ test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
476
+ // mount=/foo, candidate=/foo → tail === mount → pass through unchanged.
477
+ // Not a "real" config but pins the equality branch of the detection.
478
+ writeManifest(h.manifestPath, [
479
+ {
480
+ name: "parachute-vault",
481
+ port: 1940,
482
+ paths: ["/foo"],
483
+ health: "/foo/health",
484
+ version: "0.4.5",
485
+ installDir: "/install/dir/equality",
486
+ },
487
+ ]);
488
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
489
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
490
+ db: h.db,
491
+ issuer: ISSUER,
492
+ manifestPath: h.manifestPath,
493
+ fetchLatestVersion: async () => null,
494
+ readModuleManifest: async (installDir) => {
495
+ if (installDir === "/install/dir/equality") {
496
+ return {
497
+ name: "parachute-vault",
498
+ manifestName: "parachute-vault",
499
+ displayName: "Vault",
500
+ tagline: "",
501
+ port: 1940,
502
+ paths: ["/foo"],
503
+ health: "/foo/health",
504
+ managementUrl: "/foo",
505
+ } as unknown as Awaited<
506
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
507
+ >;
508
+ }
509
+ return null;
510
+ },
511
+ });
512
+ const body = (await res.json()) as {
513
+ modules: Array<{ short: string; management_url: string | null }>;
514
+ };
515
+ const vault = body.modules.find((m) => m.short === "vault");
516
+ expect(vault?.management_url).toBe("/foo");
517
+ });
518
+
368
519
  test("management_url is null when the module declares neither managementUrl nor uiUrl (hub#342)", async () => {
369
520
  // Scribe + runner today: no managementUrl declared yet. The SPA's
370
521
  // "Open" button renders disabled with a follow-up tooltip in that
@@ -0,0 +1,423 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ CHROME_OPT_OUT_PREFIXES,
4
+ MAX_INJECT_SIZE_BYTES,
5
+ buildChromeForRequest,
6
+ injectChromeIntoHtml,
7
+ injectChromeIntoResponse,
8
+ renderChromeStrip,
9
+ shouldInjectChrome,
10
+ } from "../chrome-strip.ts";
11
+
12
+ // Workstream G — persistent cross-surface chrome strip injected into proxied
13
+ // HTML responses. The injection seam sits in hub-server.ts; this suite
14
+ // pins the pure behavior of the chrome-strip module itself.
15
+
16
+ describe("renderChromeStrip", () => {
17
+ test("signed-out: renders Sign in link with default next=/ when no displayName", () => {
18
+ const html = renderChromeStrip({});
19
+ expect(html).toContain("pc-chrome");
20
+ expect(html).toContain('href="/login?next=%2F"');
21
+ expect(html).toContain("Sign in");
22
+ expect(html).not.toContain("Signed in as");
23
+ expect(html).not.toContain("Sign out");
24
+ });
25
+
26
+ test("signed-out: nextPath is URL-encoded into the Sign in link", () => {
27
+ const html = renderChromeStrip({ nextPath: "/admin/vaults?show=all" });
28
+ expect(html).toContain('href="/login?next=%2Fadmin%2Fvaults%3Fshow%3Dall"');
29
+ });
30
+
31
+ test("signed-in: renders 'Signed in as <name>' + Sign out form with CSRF input", () => {
32
+ const html = renderChromeStrip({
33
+ displayName: "aaron",
34
+ csrfToken: "tok-abc123",
35
+ });
36
+ expect(html).toContain("Signed in as <strong>aaron</strong>");
37
+ expect(html).toContain('action="/logout"');
38
+ expect(html).toContain('method="POST"');
39
+ expect(html).toContain('value="tok-abc123"');
40
+ expect(html).toContain("Sign out");
41
+ expect(html).not.toContain("Sign in</a>");
42
+ });
43
+
44
+ test("signed-in: HTML-escapes displayName to prevent XSS", () => {
45
+ const html = renderChromeStrip({
46
+ displayName: '<img src=x onerror="alert(1)">',
47
+ csrfToken: "tok",
48
+ });
49
+ expect(html).not.toContain("<img src=x");
50
+ expect(html).toContain("&lt;img src=x");
51
+ });
52
+
53
+ test("includes the inlined SVG brand mark + Parachute wordmark", () => {
54
+ const html = renderChromeStrip({});
55
+ expect(html).toContain("<svg");
56
+ expect(html).toContain("pc-chrome-mark-clip");
57
+ expect(html).toContain(">Parachute<");
58
+ });
59
+
60
+ test("includes a Home link in the nav cluster", () => {
61
+ const html = renderChromeStrip({});
62
+ expect(html).toMatch(/<nav[^>]+pc-chrome-nav[^>]+>[\s\S]*<a href="\/">Home<\/a>/);
63
+ });
64
+
65
+ test("emits a self-contained <style> block so the strip works on token-less surfaces", () => {
66
+ const html = renderChromeStrip({});
67
+ expect(html).toContain("<style>");
68
+ expect(html).toContain(".pc-chrome");
69
+ // Token shim fallbacks present so surfaces without --bg-soft / --fg /
70
+ // --accent declared still get a usable strip.
71
+ expect(html).toContain("--pc-chrome-bg-soft: var(--bg-soft");
72
+ expect(html).toContain("--pc-chrome-fg: var(--fg");
73
+ expect(html).toContain("--pc-chrome-accent: var(--accent");
74
+ });
75
+ });
76
+
77
+ describe("shouldInjectChrome", () => {
78
+ test("default: returns true for typical proxied paths", () => {
79
+ expect(shouldInjectChrome("/")).toBe(true);
80
+ expect(shouldInjectChrome("/admin/vaults")).toBe(true);
81
+ expect(shouldInjectChrome("/scribe/admin")).toBe(true);
82
+ expect(shouldInjectChrome("/vault/default/admin/")).toBe(true);
83
+ expect(shouldInjectChrome("/app/admin/modules")).toBe(true);
84
+ });
85
+
86
+ test("default: opts out the Notes PWA at /app/notes/*", () => {
87
+ expect(shouldInjectChrome("/app/notes")).toBe(false);
88
+ expect(shouldInjectChrome("/app/notes/")).toBe(false);
89
+ expect(shouldInjectChrome("/app/notes/index.html")).toBe(false);
90
+ expect(shouldInjectChrome("/app/notes/assets/index-XXX.js")).toBe(false);
91
+ });
92
+
93
+ test("opt-out prefix matching does not over-match sibling paths", () => {
94
+ // `/app/notesbook` must NOT match `/app/notes/` — startsWith check
95
+ // requires a trailing slash boundary.
96
+ expect(shouldInjectChrome("/app/notesbook")).toBe(true);
97
+ expect(shouldInjectChrome("/app/notes-archive/")).toBe(true);
98
+ });
99
+
100
+ test("custom opt-out list is honored", () => {
101
+ expect(shouldInjectChrome("/foo/bar", ["/foo/"])).toBe(false);
102
+ expect(shouldInjectChrome("/baz", ["/foo/"])).toBe(true);
103
+ });
104
+
105
+ test("trailing slashes in opt-out prefixes are normalized", () => {
106
+ expect(shouldInjectChrome("/foo", ["/foo/"])).toBe(false);
107
+ expect(shouldInjectChrome("/foo", ["/foo"])).toBe(false);
108
+ expect(shouldInjectChrome("/foo/bar", ["/foo/"])).toBe(false);
109
+ expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
110
+ });
111
+
112
+ test("the canonical opt-out list contains /app/notes/", () => {
113
+ expect(CHROME_OPT_OUT_PREFIXES).toContain("/app/notes/");
114
+ });
115
+ });
116
+
117
+ describe("injectChromeIntoHtml", () => {
118
+ const chrome = "<header>CHROME</header>";
119
+
120
+ test("inserts immediately after <body>", () => {
121
+ const html = "<!doctype html><html><body><h1>Hello</h1></body></html>";
122
+ const out = injectChromeIntoHtml(html, chrome);
123
+ expect(out).toBe(
124
+ "<!doctype html><html><body><header>CHROME</header><h1>Hello</h1></body></html>",
125
+ );
126
+ });
127
+
128
+ test("handles <body> with attributes", () => {
129
+ const html = '<html><body class="dark" data-theme="dark"><div>x</div></body></html>';
130
+ const out = injectChromeIntoHtml(html, chrome);
131
+ expect(out).toBe(
132
+ '<html><body class="dark" data-theme="dark"><header>CHROME</header><div>x</div></body></html>',
133
+ );
134
+ });
135
+
136
+ test("handles <BODY> (uppercase) case-insensitively", () => {
137
+ const html = "<HTML><BODY><P>x</P></BODY></HTML>";
138
+ const out = injectChromeIntoHtml(html, chrome);
139
+ expect(out).toContain("<BODY><header>CHROME</header><P>");
140
+ });
141
+
142
+ test("idempotent: skips injection when pc-chrome is already present", () => {
143
+ const html = '<html><body><header class="pc-chrome">existing</header></body></html>';
144
+ const out = injectChromeIntoHtml(html, chrome);
145
+ expect(out).toBe(html);
146
+ });
147
+
148
+ test("returns the original HTML when no <body> tag is present (fragment shape)", () => {
149
+ const html = "<div>just a fragment</div>";
150
+ const out = injectChromeIntoHtml(html, chrome);
151
+ expect(out).toBe(html);
152
+ });
153
+
154
+ test("inserts at the first <body> only, not at sub-occurrences in content", () => {
155
+ const html =
156
+ "<html><body><p>The body of an email mentions <body>twice</body></p></body></html>";
157
+ const out = injectChromeIntoHtml(html, chrome);
158
+ // Confirm the first injection happens at the real <body>, content after
159
+ // is preserved literally.
160
+ expect(out.indexOf(chrome)).toBe(html.indexOf("<body>") + "<body>".length);
161
+ // Original (literal) content body-tags still present in the slice that
162
+ // follows the injection point.
163
+ expect(out).toContain("mentions <body>twice</body>");
164
+ });
165
+ });
166
+
167
+ describe("injectChromeIntoResponse", () => {
168
+ const chrome = "<header>CHROME</header>";
169
+
170
+ test("injects into 200 text/html responses", async () => {
171
+ const res = new Response("<html><body>hi</body></html>", {
172
+ status: 200,
173
+ headers: { "content-type": "text/html; charset=utf-8" },
174
+ });
175
+ const out = await injectChromeIntoResponse(res, {
176
+ chromeHtml: chrome,
177
+ pathname: "/some/admin",
178
+ });
179
+ const body = await out.text();
180
+ expect(body).toContain("<header>CHROME</header>");
181
+ expect(body).toContain("<body><header>CHROME</header>hi</body>");
182
+ expect(out.status).toBe(200);
183
+ expect(out.headers.get("content-type")).toBe("text/html; charset=utf-8");
184
+ });
185
+
186
+ test("strips Content-Length after rewrite so the framework recomputes it", async () => {
187
+ const original = "<html><body>hi</body></html>";
188
+ const res = new Response(original, {
189
+ status: 200,
190
+ headers: {
191
+ "content-type": "text/html",
192
+ "content-length": String(original.length),
193
+ },
194
+ });
195
+ const out = await injectChromeIntoResponse(res, {
196
+ chromeHtml: chrome,
197
+ pathname: "/x",
198
+ });
199
+ expect(out.headers.get("content-length")).toBeNull();
200
+ });
201
+
202
+ test("preserves other headers (set-cookie, cache-control, x-foo)", async () => {
203
+ const res = new Response("<html><body>x</body></html>", {
204
+ status: 200,
205
+ headers: {
206
+ "content-type": "text/html",
207
+ "set-cookie": "foo=bar; HttpOnly",
208
+ "cache-control": "no-store",
209
+ "x-debug": "yep",
210
+ },
211
+ });
212
+ const out = await injectChromeIntoResponse(res, {
213
+ chromeHtml: chrome,
214
+ pathname: "/x",
215
+ });
216
+ expect(out.headers.get("set-cookie")).toBe("foo=bar; HttpOnly");
217
+ expect(out.headers.get("cache-control")).toBe("no-store");
218
+ expect(out.headers.get("x-debug")).toBe("yep");
219
+ });
220
+
221
+ test("passes through non-HTML responses unchanged", async () => {
222
+ const payload = JSON.stringify({ ok: true });
223
+ const res = new Response(payload, {
224
+ status: 200,
225
+ headers: { "content-type": "application/json" },
226
+ });
227
+ const out = await injectChromeIntoResponse(res, {
228
+ chromeHtml: chrome,
229
+ pathname: "/api/something",
230
+ });
231
+ // Identity: same Response instance is returned untouched.
232
+ expect(out).toBe(res);
233
+ expect(await out.text()).toBe(payload);
234
+ });
235
+
236
+ test("passes through JS / CSS asset responses unchanged (text/javascript, text/css)", async () => {
237
+ const js = new Response("console.log(1);", {
238
+ status: 200,
239
+ headers: { "content-type": "text/javascript" },
240
+ });
241
+ const css = new Response(".x{color:red}", {
242
+ status: 200,
243
+ headers: { "content-type": "text/css" },
244
+ });
245
+ const jsOut = await injectChromeIntoResponse(js, { chromeHtml: chrome, pathname: "/x.js" });
246
+ const cssOut = await injectChromeIntoResponse(css, { chromeHtml: chrome, pathname: "/x.css" });
247
+ expect(jsOut).toBe(js);
248
+ expect(cssOut).toBe(css);
249
+ });
250
+
251
+ test("passes through responses on opt-out paths (/app/notes/) unchanged", async () => {
252
+ const res = new Response("<html><body>notes</body></html>", {
253
+ status: 200,
254
+ headers: { "content-type": "text/html" },
255
+ });
256
+ const out = await injectChromeIntoResponse(res, {
257
+ chromeHtml: chrome,
258
+ pathname: "/app/notes/",
259
+ });
260
+ expect(out).toBe(res);
261
+ expect(await out.text()).toBe("<html><body>notes</body></html>");
262
+ });
263
+
264
+ test("passes through responses on opt-out sub-paths (/app/notes/assets/x.js)", async () => {
265
+ const res = new Response("<html><body>notes</body></html>", {
266
+ status: 200,
267
+ headers: { "content-type": "text/html" },
268
+ });
269
+ const out = await injectChromeIntoResponse(res, {
270
+ chromeHtml: chrome,
271
+ pathname: "/app/notes/index.html",
272
+ });
273
+ expect(out).toBe(res);
274
+ });
275
+
276
+ test("passes through non-200 responses unchanged (redirects, errors)", async () => {
277
+ const r301 = new Response(null, { status: 301, headers: { location: "/x" } });
278
+ const r404 = new Response("<html><body>404</body></html>", {
279
+ status: 404,
280
+ headers: { "content-type": "text/html" },
281
+ });
282
+ const r500 = new Response("<html><body>oops</body></html>", {
283
+ status: 500,
284
+ headers: { "content-type": "text/html" },
285
+ });
286
+ expect(await injectChromeIntoResponse(r301, { chromeHtml: chrome, pathname: "/x" })).toBe(r301);
287
+ expect(await injectChromeIntoResponse(r404, { chromeHtml: chrome, pathname: "/x" })).toBe(r404);
288
+ expect(await injectChromeIntoResponse(r500, { chromeHtml: chrome, pathname: "/x" })).toBe(r500);
289
+ });
290
+
291
+ test("skips injection (preserves bytes verbatim) when buffered body exceeds the size cap", async () => {
292
+ const small = 1024; // 1 KB cap, easy to exceed in test
293
+ const big = `<html><body>${"x".repeat(small + 200)}</body></html>`;
294
+ const res = new Response(big, {
295
+ status: 200,
296
+ headers: { "content-type": "text/html" },
297
+ });
298
+ const out = await injectChromeIntoResponse(res, {
299
+ chromeHtml: chrome,
300
+ pathname: "/big",
301
+ maxSizeBytes: small,
302
+ });
303
+ const outBody = await out.text();
304
+ expect(outBody).toBe(big);
305
+ expect(outBody).not.toContain("<header>CHROME</header>");
306
+ });
307
+
308
+ test("short-circuits via declared Content-Length when it exceeds the cap (no buffer drain)", async () => {
309
+ const declared = MAX_INJECT_SIZE_BYTES + 1;
310
+ const res = new Response("<html><body>doesnt matter</body></html>", {
311
+ status: 200,
312
+ headers: {
313
+ "content-type": "text/html",
314
+ "content-length": String(declared),
315
+ },
316
+ });
317
+ const out = await injectChromeIntoResponse(res, {
318
+ chromeHtml: chrome,
319
+ pathname: "/x",
320
+ });
321
+ // Identity-pass — original response untouched. The content-length
322
+ // hint diverts before the buffer drain.
323
+ expect(out).toBe(res);
324
+ });
325
+
326
+ test("malformed Content-Length (non-numeric) does not abort injection — falls through to buffer path", async () => {
327
+ const res = new Response("<html><body>hi</body></html>", {
328
+ status: 200,
329
+ headers: {
330
+ "content-type": "text/html",
331
+ "content-length": "not-a-number",
332
+ },
333
+ });
334
+ const out = await injectChromeIntoResponse(res, {
335
+ chromeHtml: chrome,
336
+ pathname: "/x",
337
+ });
338
+ const body = await out.text();
339
+ expect(body).toContain("<header>CHROME</header>");
340
+ });
341
+
342
+ test("HTML response without a <body> tag is passed through (no double-wrap)", async () => {
343
+ const res = new Response("<div>fragment</div>", {
344
+ status: 200,
345
+ headers: { "content-type": "text/html" },
346
+ });
347
+ const out = await injectChromeIntoResponse(res, {
348
+ chromeHtml: chrome,
349
+ pathname: "/x",
350
+ });
351
+ expect(await out.text()).toBe("<div>fragment</div>");
352
+ expect(out.headers.get("content-type")).toBe("text/html");
353
+ });
354
+
355
+ test("response that already contains pc-chrome is left untouched (idempotence)", async () => {
356
+ const body = '<html><body><header class="pc-chrome">existing</header>x</body></html>';
357
+ const res = new Response(body, {
358
+ status: 200,
359
+ headers: { "content-type": "text/html" },
360
+ });
361
+ const out = await injectChromeIntoResponse(res, {
362
+ chromeHtml: chrome,
363
+ pathname: "/x",
364
+ });
365
+ expect(await out.text()).toBe(body);
366
+ });
367
+ });
368
+
369
+ describe("buildChromeForRequest", () => {
370
+ test("signed-out: returns chromeHtml + no setCookie when there's no active session", () => {
371
+ const req = new Request("https://hub.example/admin/vaults");
372
+ const { chromeHtml, setCookie } = buildChromeForRequest(req, {
373
+ findActiveSession: () => null,
374
+ getUsername: () => null,
375
+ });
376
+ expect(chromeHtml).toContain("Sign in");
377
+ expect(setCookie).toBeUndefined();
378
+ });
379
+
380
+ test("signed-out: passes the current pathname+search through as the login next= target", () => {
381
+ const req = new Request("https://hub.example/admin/vaults?show=all");
382
+ const { chromeHtml } = buildChromeForRequest(req, {
383
+ findActiveSession: () => null,
384
+ getUsername: () => null,
385
+ });
386
+ expect(chromeHtml).toContain('href="/login?next=%2Fadmin%2Fvaults%3Fshow%3Dall"');
387
+ });
388
+
389
+ test("signed-in: returns chromeHtml carrying the username", () => {
390
+ const req = new Request("https://hub.example/admin/vaults", {
391
+ headers: { cookie: "parachute_hub_session=sid; parachute_hub_csrf=fixed-csrf-token" },
392
+ });
393
+ const { chromeHtml } = buildChromeForRequest(req, {
394
+ findActiveSession: () => ({ userId: "user-1" }),
395
+ getUsername: () => "aaron",
396
+ });
397
+ expect(chromeHtml).toContain("Signed in as <strong>aaron</strong>");
398
+ expect(chromeHtml).toContain('value="fixed-csrf-token"');
399
+ });
400
+
401
+ test("signed-in: mints a CSRF cookie when none is present, threads setCookie back", () => {
402
+ const req = new Request("https://hub.example/admin/vaults", {
403
+ headers: { cookie: "parachute_hub_session=sid" },
404
+ });
405
+ const { chromeHtml, setCookie } = buildChromeForRequest(req, {
406
+ findActiveSession: () => ({ userId: "user-1" }),
407
+ getUsername: () => "aaron",
408
+ });
409
+ expect(chromeHtml).toContain("Signed in as");
410
+ expect(setCookie).toBeDefined();
411
+ expect(setCookie).toContain("parachute_hub_csrf=");
412
+ });
413
+
414
+ test("session resolved but user lookup misses: degrades to signed-out chrome", () => {
415
+ const req = new Request("https://hub.example/admin/vaults");
416
+ const { chromeHtml, setCookie } = buildChromeForRequest(req, {
417
+ findActiveSession: () => ({ userId: "user-1" }),
418
+ getUsername: () => null,
419
+ });
420
+ expect(chromeHtml).toContain("Sign in");
421
+ expect(setCookie).toBeUndefined();
422
+ });
423
+ });