@openparachute/hub 0.6.4-rc.3 → 0.6.4-rc.5

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.
@@ -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 'Configure / back up this vault ↗' deep-link button for an assigned vault", async () => {
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
  });
@@ -11,6 +11,7 @@ import {
11
11
  listGrantsForUser,
12
12
  recordGrant,
13
13
  revokeGrant,
14
+ userHasVaultGrant,
14
15
  } from "../grants.ts";
15
16
  import { hubDbPath, openHubDb } from "../hub-db.ts";
16
17
  import { createUser } from "../users.ts";
@@ -177,7 +178,13 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
177
178
  redirectUris: ["https://app.example/cb"],
178
179
  clientName: "claude-code",
179
180
  });
180
- recordGrant(h.db, h.userId, reg1.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
181
+ recordGrant(
182
+ h.db,
183
+ h.userId,
184
+ reg1.client.clientId,
185
+ ["a", "b"],
186
+ new Date("2026-04-10T00:00:00Z"),
187
+ );
181
188
  // Second DCR: same client_name="claude-code", fresh client_id, no grant yet
182
189
  const reg2 = registerClient(h.db, {
183
190
  redirectUris: ["https://app.example/cb"],
@@ -249,8 +256,20 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
249
256
  clientName: "claude-code",
250
257
  });
251
258
  recordGrant(h.db, h.userId, reg1.client.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
252
- recordGrant(h.db, h.userId, reg3.client.clientId, ["a", "c"], new Date("2026-04-15T00:00:00Z"));
253
- recordGrant(h.db, h.userId, reg2.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
259
+ recordGrant(
260
+ h.db,
261
+ h.userId,
262
+ reg3.client.clientId,
263
+ ["a", "c"],
264
+ new Date("2026-04-15T00:00:00Z"),
265
+ );
266
+ recordGrant(
267
+ h.db,
268
+ h.userId,
269
+ reg2.client.clientId,
270
+ ["a", "b"],
271
+ new Date("2026-04-10T00:00:00Z"),
272
+ );
254
273
  const grant = findGrantByClientName(h.db, h.userId, "claude-code");
255
274
  // Most recent = reg3's grant (2026-04-15)
256
275
  expect(grant?.clientId).toBe(reg3.client.clientId);
@@ -267,9 +286,19 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
267
286
  redirectUris: ["https://app.example/cb"],
268
287
  clientName: "claude-code",
269
288
  });
270
- recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read", "vault:default:write"]);
271
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"])).toBe(true);
272
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(true);
289
+ recordGrant(h.db, h.userId, reg.client.clientId, [
290
+ "vault:default:read",
291
+ "vault:default:write",
292
+ ]);
293
+ expect(
294
+ isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"]),
295
+ ).toBe(true);
296
+ expect(
297
+ isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
298
+ "vault:default:read",
299
+ "vault:default:write",
300
+ ]),
301
+ ).toBe(true);
273
302
  } finally {
274
303
  h.cleanup();
275
304
  }
@@ -284,8 +313,15 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
284
313
  });
285
314
  recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read"]);
286
315
  // Asking for write — not previously granted
287
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"])).toBe(false);
288
- expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(false);
316
+ expect(
317
+ isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"]),
318
+ ).toBe(false);
319
+ expect(
320
+ isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
321
+ "vault:default:read",
322
+ "vault:default:write",
323
+ ]),
324
+ ).toBe(false);
289
325
  } finally {
290
326
  h.cleanup();
291
327
  }
@@ -304,4 +340,58 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
304
340
  h.cleanup();
305
341
  }
306
342
  });
343
+
344
+ // --- userHasVaultGrant (onboarding "has connected an AI?" signal) --------
345
+
346
+ test("userHasVaultGrant: false when the user has no grants at all", async () => {
347
+ const h = await harness();
348
+ try {
349
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
350
+ } finally {
351
+ h.cleanup();
352
+ }
353
+ });
354
+
355
+ test("userHasVaultGrant: true when a grant's scopes touch the vault", async () => {
356
+ const h = await harness();
357
+ try {
358
+ recordGrant(h.db, h.userId, h.clientId, ["vault:default:read", "vault:default:write"]);
359
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(true);
360
+ } finally {
361
+ h.cleanup();
362
+ }
363
+ });
364
+
365
+ test("userHasVaultGrant: false when the grant touches a DIFFERENT vault", async () => {
366
+ const h = await harness();
367
+ try {
368
+ recordGrant(h.db, h.userId, h.clientId, ["vault:work:read"]);
369
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
370
+ expect(userHasVaultGrant(h.db, h.userId, "work")).toBe(true);
371
+ } finally {
372
+ h.cleanup();
373
+ }
374
+ });
375
+
376
+ test("userHasVaultGrant: non-vault scopes don't count as a connection", async () => {
377
+ const h = await harness();
378
+ try {
379
+ recordGrant(h.db, h.userId, h.clientId, ["parachute:host:auth", "vault:read"]);
380
+ // `vault:read` (no name segment) is a generic scope, not vault:<name>:.
381
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
382
+ } finally {
383
+ h.cleanup();
384
+ }
385
+ });
386
+
387
+ test("userHasVaultGrant: prefix isn't substring-fooled (vault:default-2 ≠ default)", async () => {
388
+ const h = await harness();
389
+ try {
390
+ recordGrant(h.db, h.userId, h.clientId, ["vault:default-2:read"]);
391
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
392
+ expect(userHasVaultGrant(h.db, h.userId, "default-2")).toBe(true);
393
+ } finally {
394
+ h.cleanup();
395
+ }
396
+ });
307
397
  });