@openparachute/vault 0.4.9-rc.5 → 0.4.9-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.5",
3
+ "version": "0.4.9-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Tests for the GitHub Device Flow client. All HTTP calls go through an
3
+ * injected fetch — no real network round-trips, no real GitHub OAuth app.
4
+ *
5
+ * Coverage:
6
+ * - requestDeviceCode happy path + missing-field error
7
+ * - pollForToken: granted / pending / slow_down / expired / denied
8
+ * - fetchUser happy path
9
+ * - listRepos: single-page, paginated, truncated
10
+ * - createRepo happy path
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+
15
+ import {
16
+ createRepo,
17
+ fetchUser,
18
+ GITHUB_CLIENT_ID_PLACEHOLDER,
19
+ getGithubClientId,
20
+ isPlaceholderClientId,
21
+ listRepos,
22
+ pollForToken,
23
+ requestDeviceCode,
24
+ type FetchLike,
25
+ } from "./github-device-flow.ts";
26
+
27
+ /** Build a mock fetch that returns a predefined response per URL match. */
28
+ function mockFetch(
29
+ responses: Array<{
30
+ match: (url: string) => boolean;
31
+ response: {
32
+ ok: boolean;
33
+ status: number;
34
+ body: unknown;
35
+ };
36
+ }>,
37
+ ): FetchLike {
38
+ let callIdx = 0;
39
+ return async (url) => {
40
+ // Walk in order; allow each handler to be hit once unless multi-use.
41
+ for (let i = callIdx; i < responses.length; i++) {
42
+ if (responses[i]!.match(url)) {
43
+ callIdx = i + 1;
44
+ const r = responses[i]!.response;
45
+ return {
46
+ ok: r.ok,
47
+ status: r.status,
48
+ text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body)),
49
+ json: async () => r.body,
50
+ };
51
+ }
52
+ }
53
+ throw new Error(`mockFetch: no matching response for ${url}`);
54
+ };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Client id helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe("client id helpers", () => {
62
+ test("getGithubClientId reads from env when set", () => {
63
+ const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
64
+ try {
65
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.realclient";
66
+ expect(getGithubClientId()).toBe("Iv1.realclient");
67
+ } finally {
68
+ if (prev === undefined) delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
69
+ else process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
70
+ }
71
+ });
72
+
73
+ test("getGithubClientId falls back to placeholder when env unset", () => {
74
+ const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
75
+ delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
76
+ try {
77
+ expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_PLACEHOLDER);
78
+ } finally {
79
+ if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
80
+ }
81
+ });
82
+
83
+ test("isPlaceholderClientId catches the literal + the substring", () => {
84
+ expect(isPlaceholderClientId(GITHUB_CLIENT_ID_PLACEHOLDER)).toBe(true);
85
+ expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
86
+ expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
87
+ });
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // requestDeviceCode
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe("requestDeviceCode", () => {
95
+ test("returns the GitHub device-code tuple on success", async () => {
96
+ const fetcher = mockFetch([
97
+ {
98
+ match: (u) => u.includes("/login/device/code"),
99
+ response: {
100
+ ok: true,
101
+ status: 200,
102
+ body: {
103
+ device_code: "abc123",
104
+ user_code: "XXXX-YYYY",
105
+ verification_uri: "https://github.com/login/device",
106
+ expires_in: 900,
107
+ interval: 5,
108
+ },
109
+ },
110
+ },
111
+ ]);
112
+ const result = await requestDeviceCode("Iv1.test", fetcher);
113
+ expect(result.device_code).toBe("abc123");
114
+ expect(result.user_code).toBe("XXXX-YYYY");
115
+ expect(result.interval).toBe(5);
116
+ });
117
+
118
+ test("throws on missing required fields (bad shape from upstream)", async () => {
119
+ const fetcher = mockFetch([
120
+ {
121
+ match: () => true,
122
+ response: { ok: true, status: 200, body: { user_code: "X" } },
123
+ },
124
+ ]);
125
+ await expect(requestDeviceCode("Iv1.test", fetcher)).rejects.toThrow(
126
+ /missing required fields/,
127
+ );
128
+ });
129
+
130
+ test("throws on non-2xx HTTP response", async () => {
131
+ const fetcher = mockFetch([
132
+ {
133
+ match: () => true,
134
+ response: { ok: false, status: 404, body: "not found" },
135
+ },
136
+ ]);
137
+ await expect(requestDeviceCode("Iv1.test", fetcher)).rejects.toThrow(/404/);
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // pollForToken — every spec state
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe("pollForToken", () => {
146
+ test("granted state with access token", async () => {
147
+ const fetcher = mockFetch([
148
+ {
149
+ match: () => true,
150
+ response: {
151
+ ok: true,
152
+ status: 200,
153
+ body: { access_token: "gho_real", scope: "repo", token_type: "bearer" },
154
+ },
155
+ },
156
+ ]);
157
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
158
+ expect(r.state).toBe("granted");
159
+ if (r.state === "granted") {
160
+ expect(r.access_token).toBe("gho_real");
161
+ expect(r.scope).toBe("repo");
162
+ }
163
+ });
164
+
165
+ test("pending state", async () => {
166
+ const fetcher = mockFetch([
167
+ {
168
+ match: () => true,
169
+ response: {
170
+ ok: true,
171
+ status: 200,
172
+ body: { error: "authorization_pending" },
173
+ },
174
+ },
175
+ ]);
176
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
177
+ expect(r.state).toBe("pending");
178
+ });
179
+
180
+ test("slow_down state carries new interval", async () => {
181
+ const fetcher = mockFetch([
182
+ {
183
+ match: () => true,
184
+ response: {
185
+ ok: true,
186
+ status: 200,
187
+ body: { error: "slow_down", interval: 10 },
188
+ },
189
+ },
190
+ ]);
191
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
192
+ expect(r.state).toBe("slow_down");
193
+ if (r.state === "slow_down") expect(r.interval).toBe(10);
194
+ });
195
+
196
+ test("expired state", async () => {
197
+ const fetcher = mockFetch([
198
+ {
199
+ match: () => true,
200
+ response: { ok: true, status: 200, body: { error: "expired_token" } },
201
+ },
202
+ ]);
203
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
204
+ expect(r.state).toBe("expired");
205
+ });
206
+
207
+ test("denied state", async () => {
208
+ const fetcher = mockFetch([
209
+ {
210
+ match: () => true,
211
+ response: { ok: true, status: 200, body: { error: "access_denied" } },
212
+ },
213
+ ]);
214
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
215
+ expect(r.state).toBe("denied");
216
+ });
217
+
218
+ test("unknown error maps to denied (defense)", async () => {
219
+ const fetcher = mockFetch([
220
+ {
221
+ match: () => true,
222
+ response: {
223
+ ok: true,
224
+ status: 200,
225
+ body: { error: "weird_new_error" },
226
+ },
227
+ },
228
+ ]);
229
+ const r = await pollForToken("Iv1.test", "dev_code", fetcher);
230
+ expect(r.state).toBe("denied");
231
+ });
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // fetchUser
236
+ // ---------------------------------------------------------------------------
237
+
238
+ describe("fetchUser", () => {
239
+ test("returns login + id (and optional name)", async () => {
240
+ const fetcher = mockFetch([
241
+ {
242
+ match: (u) => u.includes("/user"),
243
+ response: {
244
+ ok: true,
245
+ status: 200,
246
+ body: { login: "aaron", id: 12345, name: "Aaron Gabriel", avatar_url: "https://x/y.png" },
247
+ },
248
+ },
249
+ ]);
250
+ const user = await fetchUser("gho_test", fetcher);
251
+ expect(user.login).toBe("aaron");
252
+ expect(user.id).toBe(12345);
253
+ expect(user.name).toBe("Aaron Gabriel");
254
+ });
255
+
256
+ test("throws on missing required fields", async () => {
257
+ const fetcher = mockFetch([
258
+ {
259
+ match: () => true,
260
+ response: { ok: true, status: 200, body: { login: "x" } },
261
+ },
262
+ ]);
263
+ await expect(fetchUser("gho_test", fetcher)).rejects.toThrow(/missing login or id/);
264
+ });
265
+ });
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // listRepos
269
+ // ---------------------------------------------------------------------------
270
+
271
+ describe("listRepos", () => {
272
+ test("single page (< perPage repos) returns repos, untruncated", async () => {
273
+ const fetcher = mockFetch([
274
+ {
275
+ match: (u) => u.includes("page=1"),
276
+ response: {
277
+ ok: true,
278
+ status: 200,
279
+ body: [
280
+ {
281
+ name: "a",
282
+ full_name: "aaron/a",
283
+ private: true,
284
+ html_url: "https://github.com/aaron/a",
285
+ description: null,
286
+ updated_at: "2026-05-28T00:00:00Z",
287
+ clone_url: "https://github.com/aaron/a.git",
288
+ owner: { login: "aaron" },
289
+ },
290
+ ],
291
+ },
292
+ },
293
+ ]);
294
+ const result = await listRepos("gho_test", { perPage: 100, maxPages: 3 }, fetcher);
295
+ expect(result.repos).toHaveLength(1);
296
+ expect(result.truncated).toBe(false);
297
+ expect(result.repos[0]!.owner).toBe("aaron");
298
+ expect(result.repos[0]!.full_name).toBe("aaron/a");
299
+ });
300
+
301
+ test("paginates until a short page (< perPage) signals the end", async () => {
302
+ const fullPage = Array.from({ length: 2 }, (_, i) => ({
303
+ name: `repo${i}`,
304
+ full_name: `aaron/repo${i}`,
305
+ private: false,
306
+ html_url: `https://github.com/aaron/repo${i}`,
307
+ description: null,
308
+ updated_at: "2026-05-28T00:00:00Z",
309
+ clone_url: `https://github.com/aaron/repo${i}.git`,
310
+ owner: { login: "aaron" },
311
+ }));
312
+ const shortPage = [
313
+ {
314
+ name: "last",
315
+ full_name: "aaron/last",
316
+ private: false,
317
+ html_url: "https://github.com/aaron/last",
318
+ description: null,
319
+ updated_at: "2026-05-28T00:00:00Z",
320
+ clone_url: "https://github.com/aaron/last.git",
321
+ owner: { login: "aaron" },
322
+ },
323
+ ];
324
+ const fetcher = mockFetch([
325
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
326
+ { match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: shortPage } },
327
+ ]);
328
+ const result = await listRepos("gho_test", { perPage: 2, maxPages: 3 }, fetcher);
329
+ expect(result.repos).toHaveLength(3);
330
+ expect(result.truncated).toBe(false);
331
+ });
332
+
333
+ test("marks truncated when maxPages cap hit and page still full", async () => {
334
+ const fullPage = Array.from({ length: 2 }, (_, i) => ({
335
+ name: `repo${i}`,
336
+ full_name: `aaron/repo${i}`,
337
+ private: false,
338
+ html_url: `https://github.com/aaron/repo${i}`,
339
+ description: null,
340
+ updated_at: "2026-05-28T00:00:00Z",
341
+ clone_url: `https://github.com/aaron/repo${i}.git`,
342
+ owner: { login: "aaron" },
343
+ }));
344
+ const fetcher = mockFetch([
345
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
346
+ { match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
347
+ ]);
348
+ const result = await listRepos("gho_test", { perPage: 2, maxPages: 2 }, fetcher);
349
+ expect(result.repos).toHaveLength(4);
350
+ expect(result.truncated).toBe(true);
351
+ });
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // createRepo
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe("createRepo", () => {
359
+ test("creates a private repo with description", async () => {
360
+ const fetcher = mockFetch([
361
+ {
362
+ match: (u) => u.includes("/user/repos"),
363
+ response: {
364
+ ok: true,
365
+ status: 201,
366
+ body: {
367
+ name: "my-vault",
368
+ full_name: "aaron/my-vault",
369
+ private: true,
370
+ html_url: "https://github.com/aaron/my-vault",
371
+ description: "Parachute Vault mirror",
372
+ updated_at: "2026-05-28T00:00:00Z",
373
+ clone_url: "https://github.com/aaron/my-vault.git",
374
+ owner: { login: "aaron" },
375
+ },
376
+ },
377
+ },
378
+ ]);
379
+ const repo = await createRepo(
380
+ "gho_test",
381
+ { name: "my-vault", description: "Parachute Vault mirror" },
382
+ fetcher,
383
+ );
384
+ expect(repo.full_name).toBe("aaron/my-vault");
385
+ expect(repo.private).toBe(true);
386
+ expect(repo.clone_url).toBe("https://github.com/aaron/my-vault.git");
387
+ });
388
+
389
+ test("surfaces GitHub error message on failure (name taken, validation)", async () => {
390
+ const fetcher = mockFetch([
391
+ {
392
+ match: () => true,
393
+ response: {
394
+ ok: false,
395
+ status: 422,
396
+ body: { message: "Repository creation failed: name already exists" },
397
+ },
398
+ },
399
+ ]);
400
+ await expect(
401
+ createRepo("gho_test", { name: "exists" }, fetcher),
402
+ ).rejects.toThrow(/already exists/);
403
+ });
404
+ });