@openparachute/hub 0.3.0-rc.1 → 0.5.1

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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,220 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ findUnknownScopes,
7
+ isKnownScope,
8
+ loadDeclaredScopes,
9
+ parseScopeString,
10
+ } from "../scope-registry.ts";
11
+
12
+ describe("parseScopeString", () => {
13
+ test("splits on whitespace per RFC 6749", () => {
14
+ expect(parseScopeString("vault:read scribe:transcribe")).toEqual([
15
+ "vault:read",
16
+ "scribe:transcribe",
17
+ ]);
18
+ });
19
+
20
+ test("accepts tabs + newlines + multi-space runs", () => {
21
+ expect(parseScopeString("vault:read\tscribe:transcribe\n channel:send")).toEqual([
22
+ "vault:read",
23
+ "scribe:transcribe",
24
+ "channel:send",
25
+ ]);
26
+ });
27
+
28
+ test("empty string yields empty array", () => {
29
+ expect(parseScopeString("")).toEqual([]);
30
+ expect(parseScopeString(" ")).toEqual([]);
31
+ });
32
+ });
33
+
34
+ describe("isKnownScope", () => {
35
+ const declared = new Set(["vault:read", "vault:write", "scribe:transcribe", "hub:admin"]);
36
+
37
+ test("exact match passes", () => {
38
+ expect(isKnownScope("vault:read", declared)).toBe(true);
39
+ expect(isKnownScope("scribe:transcribe", declared)).toBe(true);
40
+ });
41
+
42
+ test("unknown scopes fail", () => {
43
+ expect(isKnownScope("frobnicate:everything", declared)).toBe(false);
44
+ expect(isKnownScope("vault:exfiltrate", declared)).toBe(false);
45
+ });
46
+
47
+ test("per-resource narrowing collapses to <svc>:<verb>", () => {
48
+ expect(isKnownScope("vault:work:read", declared)).toBe(true);
49
+ expect(isKnownScope("vault:default:write", declared)).toBe(true);
50
+ expect(isKnownScope("scribe:groq:transcribe", declared)).toBe(true);
51
+ });
52
+
53
+ test("narrowing only collapses if collapsed form is declared", () => {
54
+ expect(isKnownScope("vault:work:exfiltrate", declared)).toBe(false);
55
+ });
56
+
57
+ test("admin scope does NOT inherit read/write at the issuer", () => {
58
+ // Inheritance is the resource server's call (vault enforces admin ⊇ write
59
+ // ⊇ read at request time). The issuer only mints what was declared.
60
+ const adminOnly = new Set(["vault:admin"]);
61
+ expect(isKnownScope("vault:read", adminOnly)).toBe(false);
62
+ });
63
+
64
+ test("malformed scopes (no colon, single segment) fail", () => {
65
+ expect(isKnownScope("vaultread", declared)).toBe(false);
66
+ expect(isKnownScope("vault:", declared)).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe("findUnknownScopes", () => {
71
+ const declared = new Set(["vault:read", "vault:write", "scribe:transcribe"]);
72
+
73
+ test("returns only the unknown ones", () => {
74
+ expect(
75
+ findUnknownScopes(["vault:read", "frobnicate:everything", "scribe:transcribe"], declared),
76
+ ).toEqual(["frobnicate:everything"]);
77
+ });
78
+
79
+ test("returns [] when all declared", () => {
80
+ expect(findUnknownScopes(["vault:read", "scribe:transcribe"], declared)).toEqual([]);
81
+ });
82
+
83
+ test("returns [] for empty input", () => {
84
+ expect(findUnknownScopes([], declared)).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe("loadDeclaredScopes", () => {
89
+ function tmp(): { dir: string; manifestPath: string; cleanup: () => void } {
90
+ const dir = mkdtempSync(join(tmpdir(), "phub-scope-reg-"));
91
+ return {
92
+ dir,
93
+ manifestPath: join(dir, "services.json"),
94
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
95
+ };
96
+ }
97
+
98
+ test("returns FIRST_PARTY_SCOPES baseline when services.json is absent", () => {
99
+ const { manifestPath, cleanup } = tmp();
100
+ try {
101
+ const declared = loadDeclaredScopes({ manifestPath });
102
+ expect(declared.has("vault:read")).toBe(true);
103
+ expect(declared.has("scribe:transcribe")).toBe(true);
104
+ expect(declared.has("hub:admin")).toBe(true);
105
+ } finally {
106
+ cleanup();
107
+ }
108
+ });
109
+
110
+ test("unions module.json scopes.defines on top of FIRST_PARTY_SCOPES", () => {
111
+ const { manifestPath, cleanup } = tmp();
112
+ try {
113
+ writeFileSync(
114
+ manifestPath,
115
+ JSON.stringify({
116
+ services: [
117
+ {
118
+ name: "@acme/widget",
119
+ port: 1950,
120
+ paths: ["/widget"],
121
+ health: "/healthz",
122
+ version: "0.0.0-linked",
123
+ },
124
+ ],
125
+ }),
126
+ );
127
+ const declared = loadDeclaredScopes({
128
+ manifestPath,
129
+ readModuleScopes: (pkg) =>
130
+ pkg === "@acme/widget" ? ["widget:read", "widget:write"] : null,
131
+ });
132
+ expect(declared.has("vault:read")).toBe(true); // baseline
133
+ expect(declared.has("widget:read")).toBe(true);
134
+ expect(declared.has("widget:write")).toBe(true);
135
+ } finally {
136
+ cleanup();
137
+ }
138
+ });
139
+
140
+ test("readModuleScopes receives installDir from services.json (closes #85 follow-up)", () => {
141
+ // Regression: scope-registry was looking up by services.json `name` in
142
+ // bun-globals. For third-party modules where name (canonical short like
143
+ // "agent") differs from the npm package name on disk ("nanoagent" for
144
+ // forks), that lookup fails and the module's scopes are never declared.
145
+ // installDir from hub#84 is the correct path source.
146
+ const { manifestPath, cleanup } = tmp();
147
+ try {
148
+ writeFileSync(
149
+ manifestPath,
150
+ JSON.stringify({
151
+ services: [
152
+ {
153
+ name: "agent",
154
+ port: 1944,
155
+ paths: ["/agent"],
156
+ health: "/api/health",
157
+ version: "0.0.0-linked",
158
+ installDir: "/Users/test/ParachuteComputer/parachute-agent",
159
+ },
160
+ ],
161
+ }),
162
+ );
163
+ const calls: { pkg: string; installDir: string | undefined }[] = [];
164
+ const declared = loadDeclaredScopes({
165
+ manifestPath,
166
+ readModuleScopes: (pkg, installDir) => {
167
+ calls.push({ pkg, installDir });
168
+ return pkg === "agent" ? ["agent:read", "agent:write", "agent:admin"] : null;
169
+ },
170
+ });
171
+ expect(calls).toEqual([
172
+ { pkg: "agent", installDir: "/Users/test/ParachuteComputer/parachute-agent" },
173
+ ]);
174
+ expect(declared.has("agent:read")).toBe(true);
175
+ expect(declared.has("agent:write")).toBe(true);
176
+ expect(declared.has("agent:admin")).toBe(true);
177
+ } finally {
178
+ cleanup();
179
+ }
180
+ });
181
+
182
+ test("services with no module.json don't crash the registry", () => {
183
+ const { manifestPath, cleanup } = tmp();
184
+ try {
185
+ writeFileSync(
186
+ manifestPath,
187
+ JSON.stringify({
188
+ services: [
189
+ {
190
+ name: "parachute-vault",
191
+ port: 1940,
192
+ paths: ["/vault"],
193
+ health: "/healthz",
194
+ version: "0.0.0-linked",
195
+ },
196
+ ],
197
+ }),
198
+ );
199
+ const declared = loadDeclaredScopes({
200
+ manifestPath,
201
+ readModuleScopes: () => null,
202
+ });
203
+ expect(declared.has("vault:read")).toBe(true);
204
+ } finally {
205
+ cleanup();
206
+ }
207
+ });
208
+
209
+ test("malformed services.json falls back to baseline", () => {
210
+ const { manifestPath, cleanup } = tmp();
211
+ try {
212
+ writeFileSync(manifestPath, "{not json");
213
+ const declared = loadDeclaredScopes({ manifestPath });
214
+ expect(declared.has("vault:read")).toBe(true);
215
+ expect(declared.size).toBeGreaterThan(0);
216
+ } finally {
217
+ cleanup();
218
+ }
219
+ });
220
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -174,4 +174,140 @@ describe("services-manifest", () => {
174
174
  cleanup();
175
175
  }
176
176
  });
177
+
178
+ test("round-trips optional installDir", () => {
179
+ const { path, cleanup } = makeTempPath();
180
+ try {
181
+ const full: ServiceEntry = { ...vault, installDir: "/abs/path/to/pkg" };
182
+ upsertService(full, path);
183
+ expect(readManifest(path).services[0]).toEqual(full);
184
+ } finally {
185
+ cleanup();
186
+ }
187
+ });
188
+
189
+ test("rejects non-string installDir", () => {
190
+ const { path, cleanup } = makeTempPath();
191
+ try {
192
+ expect(() => upsertService({ ...vault, installDir: 42 as unknown as string }, path)).toThrow(
193
+ /installDir/,
194
+ );
195
+ } finally {
196
+ cleanup();
197
+ }
198
+ });
199
+ });
200
+
201
+ describe("claw → agent migration", () => {
202
+ // Paraclaw was renamed to parachute-agent across the ecosystem (npm
203
+ // package, mount path, short name). Operators who upgraded hub but
204
+ // still have the old paraclaw row in services.json otherwise see a
205
+ // tile labelled "Claw" and a hub route at `/claw` while their newly
206
+ // upgraded daemon listens on `/agent`. The migration runs on
207
+ // readManifest, rewrites the row in-place, and writes back.
208
+ const claw: ServiceEntry = {
209
+ name: "claw",
210
+ port: 1944,
211
+ paths: ["/claw"],
212
+ health: "/claw/health",
213
+ version: "0.1.0",
214
+ };
215
+ const agent: ServiceEntry = {
216
+ name: "agent",
217
+ port: 1944,
218
+ paths: ["/agent"],
219
+ health: "/agent/health",
220
+ version: "0.1.0",
221
+ };
222
+
223
+ test("rewrites name + paths + health when both name=claw and paths[0]=/claw", () => {
224
+ const { path, cleanup } = makeTempPath();
225
+ try {
226
+ writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
227
+ const got = readManifest(path);
228
+ expect(got.services).toEqual([agent]);
229
+ // Persisted: a second read sees the migrated shape directly, no
230
+ // re-migration required.
231
+ const reread = JSON.parse(readFileSync(path, "utf8")) as {
232
+ services: ServiceEntry[];
233
+ };
234
+ expect(reread.services[0]?.name).toBe("agent");
235
+ expect(reread.services[0]?.paths).toEqual(["/agent"]);
236
+ expect(reread.services[0]?.health).toBe("/agent/health");
237
+ } finally {
238
+ cleanup();
239
+ }
240
+ });
241
+
242
+ test("idempotent: an already-agent entry is not rewritten and not rewritten on re-read", () => {
243
+ const { path, cleanup } = makeTempPath();
244
+ try {
245
+ writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
246
+ const beforeMtime = statSync(path).mtimeMs;
247
+ const got = readManifest(path);
248
+ expect(got.services).toEqual([agent]);
249
+ // No write back when nothing changed: mtime stays put.
250
+ const afterMtime = statSync(path).mtimeMs;
251
+ expect(afterMtime).toBe(beforeMtime);
252
+ } finally {
253
+ cleanup();
254
+ }
255
+ });
256
+
257
+ test("mixed manifest: vault and scribe are untouched, only claw migrates", () => {
258
+ const { path, cleanup } = makeTempPath();
259
+ try {
260
+ const scribe: ServiceEntry = {
261
+ name: "parachute-scribe",
262
+ port: 1943,
263
+ paths: ["/scribe"],
264
+ health: "/scribe/health",
265
+ version: "0.1.0",
266
+ };
267
+ writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
268
+ const got = readManifest(path);
269
+ expect(got.services).toHaveLength(3);
270
+ expect(got.services[0]).toEqual(vault);
271
+ expect(got.services[1]).toEqual(agent);
272
+ expect(got.services[2]).toEqual(scribe);
273
+ } finally {
274
+ cleanup();
275
+ }
276
+ });
277
+
278
+ test("preserves nested /claw paths when present (e.g. /claw/api)", () => {
279
+ const { path, cleanup } = makeTempPath();
280
+ try {
281
+ const clawNested: ServiceEntry = {
282
+ name: "claw",
283
+ port: 1944,
284
+ paths: ["/claw", "/claw/api"],
285
+ health: "/claw/api/health",
286
+ version: "0.1.0",
287
+ };
288
+ writeFileSync(path, `${JSON.stringify({ services: [clawNested] }, null, 2)}\n`);
289
+ const got = readManifest(path);
290
+ expect(got.services[0]?.paths).toEqual(["/agent", "/agent/api"]);
291
+ expect(got.services[0]?.health).toBe("/agent/api/health");
292
+ } finally {
293
+ cleanup();
294
+ }
295
+ });
296
+
297
+ test("leaves a row alone if name is claw but mount is something else (deliberate third-party reuse)", () => {
298
+ const { path, cleanup } = makeTempPath();
299
+ try {
300
+ const oddClaw: ServiceEntry = {
301
+ name: "claw",
302
+ port: 9000,
303
+ paths: ["/something-else"],
304
+ health: "/something-else/health",
305
+ version: "0.1.0",
306
+ };
307
+ writeFileSync(path, `${JSON.stringify({ services: [oddClaw] }, null, 2)}\n`);
308
+ expect(readManifest(path).services).toEqual([oddClaw]);
309
+ } finally {
310
+ cleanup();
311
+ }
312
+ });
177
313
  });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import {
7
+ SESSION_COOKIE_NAME,
8
+ buildSessionClearCookie,
9
+ buildSessionCookie,
10
+ createSession,
11
+ deleteSession,
12
+ findSession,
13
+ parseSessionCookie,
14
+ } from "../sessions.ts";
15
+ import { createUser } from "../users.ts";
16
+
17
+ async function makeDb() {
18
+ const configDir = mkdtempSync(join(tmpdir(), "phub-sessions-"));
19
+ const db = openHubDb(hubDbPath(configDir));
20
+ const user = await createUser(db, "owner", "pw");
21
+ return {
22
+ db,
23
+ userId: user.id,
24
+ cleanup: () => {
25
+ db.close();
26
+ rmSync(configDir, { recursive: true, force: true });
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("createSession + findSession", () => {
32
+ test("round-trips a session", async () => {
33
+ const { db, userId, cleanup } = await makeDb();
34
+ try {
35
+ const s = createSession(db, { userId });
36
+ const found = findSession(db, s.id);
37
+ expect(found?.userId).toBe(userId);
38
+ expect(found?.id).toBe(s.id);
39
+ } finally {
40
+ cleanup();
41
+ }
42
+ });
43
+
44
+ test("returns null for unknown id", async () => {
45
+ const { db, cleanup } = await makeDb();
46
+ try {
47
+ expect(findSession(db, "no-such-session")).toBeNull();
48
+ } finally {
49
+ cleanup();
50
+ }
51
+ });
52
+
53
+ test("returns null for expired session", async () => {
54
+ const { db, userId, cleanup } = await makeDb();
55
+ try {
56
+ const epoch = new Date("2026-01-01T00:00:00Z");
57
+ const s = createSession(db, { userId, now: () => epoch });
58
+ // Past TTL (24h).
59
+ const later = new Date(epoch.getTime() + 25 * 3600 * 1000);
60
+ expect(findSession(db, s.id, () => later)).toBeNull();
61
+ // Still valid one second before expiry.
62
+ const justBefore = new Date(epoch.getTime() + 24 * 3600 * 1000 - 1000);
63
+ expect(findSession(db, s.id, () => justBefore)?.id).toBe(s.id);
64
+ } finally {
65
+ cleanup();
66
+ }
67
+ });
68
+ });
69
+
70
+ describe("deleteSession", () => {
71
+ test("removes the session row", async () => {
72
+ const { db, userId, cleanup } = await makeDb();
73
+ try {
74
+ const s = createSession(db, { userId });
75
+ deleteSession(db, s.id);
76
+ expect(findSession(db, s.id)).toBeNull();
77
+ } finally {
78
+ cleanup();
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("buildSessionCookie", () => {
84
+ test("emits the expected attributes", () => {
85
+ const v = buildSessionCookie("abc", 86400);
86
+ expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
87
+ expect(v).toContain("HttpOnly");
88
+ expect(v).toContain("Secure");
89
+ expect(v).toContain("SameSite=Lax");
90
+ expect(v).toContain("Path=/");
91
+ expect(v).not.toContain("Path=/oauth");
92
+ expect(v).toContain("Max-Age=86400");
93
+ });
94
+ });
95
+
96
+ describe("buildSessionClearCookie", () => {
97
+ test("emits Max-Age=0", () => {
98
+ const v = buildSessionClearCookie();
99
+ expect(v).toContain(`${SESSION_COOKIE_NAME}=`);
100
+ expect(v).toContain("Max-Age=0");
101
+ expect(v).toContain("Path=/");
102
+ expect(v).not.toContain("Path=/oauth");
103
+ });
104
+ });
105
+
106
+ describe("parseSessionCookie", () => {
107
+ test("extracts the session id from a Cookie header", () => {
108
+ expect(parseSessionCookie(`${SESSION_COOKIE_NAME}=xyz`)).toBe("xyz");
109
+ expect(parseSessionCookie(`other=foo; ${SESSION_COOKIE_NAME}=xyz; bar=baz`)).toBe("xyz");
110
+ });
111
+ test("returns null when absent or empty", () => {
112
+ expect(parseSessionCookie(null)).toBeNull();
113
+ expect(parseSessionCookie("")).toBeNull();
114
+ expect(parseSessionCookie("other=foo")).toBeNull();
115
+ });
116
+ });