@openparachute/vault 0.6.0 → 0.6.2-rc.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.
@@ -7,7 +7,10 @@
7
7
  * - pollForToken: granted / pending / slow_down / expired / denied
8
8
  * - fetchUser happy path
9
9
  * - listRepos: single-page, paginated, truncated
10
- * - createRepo happy path
10
+ * - listInstallations: happy (user + org), empty, bad shape, API error
11
+ * - listInstallationRepos: happy, paginated/truncated, bad shape
12
+ * - createRepo happy path + GitHubApiError status (403 = no Administration)
13
+ * - app slug helpers + install URL
11
14
  */
12
15
 
13
16
  import { describe, expect, test } from "bun:test";
@@ -15,9 +18,15 @@ import { describe, expect, test } from "bun:test";
15
18
  import {
16
19
  createRepo,
17
20
  fetchUser,
18
- GITHUB_CLIENT_ID_PLACEHOLDER,
21
+ GITHUB_APP_SLUG_DEFAULT,
22
+ GITHUB_CLIENT_ID_DEFAULT,
23
+ getGithubAppSlug,
19
24
  getGithubClientId,
25
+ GitHubApiError,
26
+ installUrlForSlug,
20
27
  isPlaceholderClientId,
28
+ listInstallationRepos,
29
+ listInstallations,
21
30
  listRepos,
22
31
  pollForToken,
23
32
  requestDeviceCode,
@@ -70,23 +79,68 @@ describe("client id helpers", () => {
70
79
  }
71
80
  });
72
81
 
73
- test("getGithubClientId falls back to placeholder when env unset", () => {
82
+ test("getGithubClientId falls back to the shared-app default when env unset", () => {
74
83
  const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
75
84
  delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
76
85
  try {
77
- expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_PLACEHOLDER);
86
+ expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_DEFAULT);
78
87
  } finally {
79
88
  if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
80
89
  }
81
90
  });
82
91
 
83
- test("isPlaceholderClientId catches the literal + the substring", () => {
84
- expect(isPlaceholderClientId(GITHUB_CLIENT_ID_PLACEHOLDER)).toBe(true);
92
+ test("the shipped default is the registered Parachute GitHub App id", () => {
93
+ // Pin the exact value — it's public by design, and a typo'd constant
94
+ // would otherwise still pass a shape check.
95
+ expect(GITHUB_CLIENT_ID_DEFAULT).toBe("Iv23livaRF4VcvPhu3uB");
96
+ expect(isPlaceholderClientId(GITHUB_CLIENT_ID_DEFAULT)).toBe(false);
97
+ });
98
+
99
+ test("isPlaceholderClientId catches placeholder-shaped env overrides", () => {
100
+ expect(isPlaceholderClientId("Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE")).toBe(true);
85
101
  expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
86
102
  expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
87
103
  });
88
104
  });
89
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // App slug helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("app slug helpers", () => {
111
+ test("getGithubAppSlug reads from env when set (BYO-app pairing)", () => {
112
+ const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
113
+ try {
114
+ process.env.PARACHUTE_GITHUB_APP_SLUG = "my-own-app";
115
+ expect(getGithubAppSlug()).toBe("my-own-app");
116
+ } finally {
117
+ if (prev === undefined) delete process.env.PARACHUTE_GITHUB_APP_SLUG;
118
+ else process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
119
+ }
120
+ });
121
+
122
+ test("getGithubAppSlug falls back to the shared-app default when env unset", () => {
123
+ const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
124
+ delete process.env.PARACHUTE_GITHUB_APP_SLUG;
125
+ try {
126
+ expect(getGithubAppSlug()).toBe(GITHUB_APP_SLUG_DEFAULT);
127
+ } finally {
128
+ if (prev !== undefined) process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
129
+ }
130
+ });
131
+
132
+ test("the shipped default slug is the registered Parachute GitHub App's", () => {
133
+ // Pin the exact value — a typo'd slug would build a 404 install link.
134
+ expect(GITHUB_APP_SLUG_DEFAULT).toBe("parachute-computer");
135
+ });
136
+
137
+ test("installUrlForSlug builds the installations/new URL", () => {
138
+ expect(installUrlForSlug("parachute-computer")).toBe(
139
+ "https://github.com/apps/parachute-computer/installations/new",
140
+ );
141
+ });
142
+ });
143
+
90
144
  // ---------------------------------------------------------------------------
91
145
  // requestDeviceCode
92
146
  // ---------------------------------------------------------------------------
@@ -351,6 +405,187 @@ describe("listRepos", () => {
351
405
  });
352
406
  });
353
407
 
408
+ // ---------------------------------------------------------------------------
409
+ // listInstallations
410
+ // ---------------------------------------------------------------------------
411
+
412
+ describe("listInstallations", () => {
413
+ test("returns user + org installations with typed fields", async () => {
414
+ const fetcher = mockFetch([
415
+ {
416
+ match: (u) => u.includes("/user/installations"),
417
+ response: {
418
+ ok: true,
419
+ status: 200,
420
+ body: {
421
+ total_count: 2,
422
+ installations: [
423
+ {
424
+ id: 101,
425
+ app_slug: "parachute-computer",
426
+ account: { login: "aaron", type: "User" },
427
+ repository_selection: "selected",
428
+ },
429
+ {
430
+ id: 202,
431
+ app_slug: "parachute-computer",
432
+ account: { login: "unforced-org", type: "Organization" },
433
+ repository_selection: "all",
434
+ },
435
+ ],
436
+ },
437
+ },
438
+ },
439
+ ]);
440
+ const installations = await listInstallations("ghu_test", fetcher);
441
+ expect(installations).toHaveLength(2);
442
+ expect(installations[0]!.id).toBe(101);
443
+ expect(installations[0]!.account.login).toBe("aaron");
444
+ expect(installations[0]!.account.type).toBe("User");
445
+ expect(installations[0]!.repository_selection).toBe("selected");
446
+ expect(installations[1]!.account.type).toBe("Organization");
447
+ expect(installations[1]!.app_slug).toBe("parachute-computer");
448
+ });
449
+
450
+ test("empty installations array = authorized but not installed", async () => {
451
+ const fetcher = mockFetch([
452
+ {
453
+ match: () => true,
454
+ response: { ok: true, status: 200, body: { total_count: 0, installations: [] } },
455
+ },
456
+ ]);
457
+ const installations = await listInstallations("ghu_test", fetcher);
458
+ expect(installations).toEqual([]);
459
+ });
460
+
461
+ test("throws on a response missing the installations array", async () => {
462
+ const fetcher = mockFetch([
463
+ {
464
+ match: () => true,
465
+ response: { ok: true, status: 200, body: { total_count: 0 } },
466
+ },
467
+ ]);
468
+ await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
469
+ /missing installations array/,
470
+ );
471
+ });
472
+
473
+ test("throws on an item missing required fields", async () => {
474
+ const fetcher = mockFetch([
475
+ {
476
+ match: () => true,
477
+ response: {
478
+ ok: true,
479
+ status: 200,
480
+ body: { total_count: 1, installations: [{ id: "not-a-number", account: null }] },
481
+ },
482
+ },
483
+ ]);
484
+ await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
485
+ /missing required fields/,
486
+ );
487
+ });
488
+
489
+ test("throws GitHubApiError carrying the status on non-2xx", async () => {
490
+ const fetcher = mockFetch([
491
+ {
492
+ match: () => true,
493
+ response: { ok: false, status: 401, body: { message: "Bad credentials" } },
494
+ },
495
+ ]);
496
+ try {
497
+ await listInstallations("ghu_revoked", fetcher);
498
+ throw new Error("expected listInstallations to throw");
499
+ } catch (err) {
500
+ expect(err).toBeInstanceOf(GitHubApiError);
501
+ expect((err as GitHubApiError).status).toBe(401);
502
+ }
503
+ });
504
+ });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // listInstallationRepos
508
+ // ---------------------------------------------------------------------------
509
+
510
+ function installationRepoItem(i: number, owner = "aaron"): Record<string, unknown> {
511
+ return {
512
+ name: `repo${i}`,
513
+ full_name: `${owner}/repo${i}`,
514
+ private: true,
515
+ html_url: `https://github.com/${owner}/repo${i}`,
516
+ description: null,
517
+ updated_at: "2026-06-10T00:00:00Z",
518
+ clone_url: `https://github.com/${owner}/repo${i}.git`,
519
+ owner: { login: owner },
520
+ };
521
+ }
522
+
523
+ describe("listInstallationRepos", () => {
524
+ test("single page returns GitHubRepoInfo shapes from the installation", async () => {
525
+ const fetcher = mockFetch([
526
+ {
527
+ match: (u) => u.includes("/user/installations/101/repositories") && u.includes("page=1"),
528
+ response: {
529
+ ok: true,
530
+ status: 200,
531
+ body: { total_count: 1, repositories: [installationRepoItem(0)] },
532
+ },
533
+ },
534
+ ]);
535
+ const result = await listInstallationRepos("ghu_test", 101, { perPage: 100, maxPages: 3 }, fetcher);
536
+ expect(result.repos).toHaveLength(1);
537
+ expect(result.truncated).toBe(false);
538
+ expect(result.repos[0]!.full_name).toBe("aaron/repo0");
539
+ expect(result.repos[0]!.owner).toBe("aaron");
540
+ expect(result.repos[0]!.private).toBe(true);
541
+ });
542
+
543
+ test("paginates until a short page; truncates at the maxPages cap", async () => {
544
+ const fullPage = { total_count: 4, repositories: [installationRepoItem(0), installationRepoItem(1)] };
545
+ const fetcher = mockFetch([
546
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
547
+ { match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
548
+ ]);
549
+ const result = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 2 }, fetcher);
550
+ expect(result.repos).toHaveLength(4);
551
+ expect(result.truncated).toBe(true);
552
+
553
+ // Short-page end: page 2 has fewer than perPage → untruncated.
554
+ const fetcher2 = mockFetch([
555
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
556
+ {
557
+ match: (u) => u.includes("page=2"),
558
+ response: { ok: true, status: 200, body: { total_count: 3, repositories: [installationRepoItem(2)] } },
559
+ },
560
+ ]);
561
+ const result2 = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 3 }, fetcher2);
562
+ expect(result2.repos).toHaveLength(3);
563
+ expect(result2.truncated).toBe(false);
564
+ });
565
+
566
+ test("throws on a response missing the repositories array", async () => {
567
+ const fetcher = mockFetch([
568
+ { match: () => true, response: { ok: true, status: 200, body: { total_count: 0 } } },
569
+ ]);
570
+ await expect(
571
+ listInstallationRepos("ghu_test", 101, {}, fetcher),
572
+ ).rejects.toThrow(/missing repositories array/);
573
+ });
574
+
575
+ test("throws GitHubApiError carrying the status on non-2xx", async () => {
576
+ const fetcher = mockFetch([
577
+ { match: () => true, response: { ok: false, status: 404, body: { message: "Not Found" } } },
578
+ ]);
579
+ try {
580
+ await listInstallationRepos("ghu_test", 999, {}, fetcher);
581
+ throw new Error("expected listInstallationRepos to throw");
582
+ } catch (err) {
583
+ expect(err).toBeInstanceOf(GitHubApiError);
584
+ expect((err as GitHubApiError).status).toBe(404);
585
+ }
586
+ });
587
+ });
588
+
354
589
  // ---------------------------------------------------------------------------
355
590
  // createRepo
356
591
  // ---------------------------------------------------------------------------
@@ -401,4 +636,28 @@ describe("createRepo", () => {
401
636
  createRepo("gho_test", { name: "exists" }, fetcher),
402
637
  ).rejects.toThrow(/already exists/);
403
638
  });
639
+
640
+ test("403 (shared Contents-only app) throws GitHubApiError with status 403", async () => {
641
+ // POST /user/repos needs Administration:write — the shared app's
642
+ // expected failure. The route maps this status to the guided-manual
643
+ // error, so the status must survive the throw.
644
+ const fetcher = mockFetch([
645
+ {
646
+ match: () => true,
647
+ response: {
648
+ ok: false,
649
+ status: 403,
650
+ body: { message: "Resource not accessible by integration" },
651
+ },
652
+ },
653
+ ]);
654
+ try {
655
+ await createRepo("ghu_test", { name: "my-vault" }, fetcher);
656
+ throw new Error("expected createRepo to throw");
657
+ } catch (err) {
658
+ expect(err).toBeInstanceOf(GitHubApiError);
659
+ expect((err as GitHubApiError).status).toBe(403);
660
+ expect((err as Error).message).toContain("Resource not accessible");
661
+ }
662
+ });
404
663
  });