@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.
- package/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +303 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/api-account.test.ts +51 -1
- package/src/__tests__/grants.test.ts +98 -8
- package/src/account-home-ui.ts +399 -257
- package/src/account-mirror.ts +126 -0
- package/src/account-vault-token.ts +2 -0
- package/src/api-account.ts +57 -0
- package/src/grants.ts +25 -0
|
@@ -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
|
});
|
|
@@ -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(
|
|
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(
|
|
253
|
-
|
|
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, [
|
|
271
|
-
|
|
272
|
-
|
|
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(
|
|
288
|
-
|
|
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
|
});
|