@openparachute/vault 0.6.0 → 0.6.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.
@@ -27,6 +27,7 @@ import {
27
27
  handleAuthGet,
28
28
  handleAuthGithubCreateRepo,
29
29
  handleAuthGithubDeviceCode,
30
+ handleAuthGithubInstallations,
30
31
  handleAuthGithubPoll,
31
32
  handleAuthGithubRepos,
32
33
  handleAuthGithubSelectRepo,
@@ -617,10 +618,12 @@ describe("auth credential routes — device flow", () => {
617
618
  delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
618
619
  });
619
620
 
620
- test("device-code returns 503 with placeholder client_id (no env override)", async () => {
621
+ test("device-code returns 503 when the env override is placeholder-shaped", async () => {
621
622
  home = tmp("mirror-auth-placeholder-");
622
623
  makeManager(home);
623
- delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
624
+ // A real default ships in the build, so the placeholder guard is only
625
+ // reachable via a misconfigured PARACHUTE_GITHUB_CLIENT_ID override.
626
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE";
624
627
  const res = await handleAuthGithubDeviceCode();
625
628
  expect(res.status).toBe(503);
626
629
  const body = (await res.json()) as { error_type: string };
@@ -726,6 +729,12 @@ describe("auth credential routes — device flow", () => {
726
729
  expect(saved?.active_method).toBe("github_oauth");
727
730
  expect(saved?.github_oauth?.access_token).toBe("gho_real1234567890");
728
731
  expect(saved?.github_oauth?.user_login).toBe("aaron");
732
+ // vault#483: the grant also carries the history-on-link outcome (the
733
+ // dedicated branches are covered in the "history-on-link" describe).
734
+ expect("history_enabled" in (grantBody as Record<string, unknown>)).toBe(true);
735
+ // The grant may have started the mirror lifecycle (history-on-link) —
736
+ // tear it down so no safety-net timer outlives the test.
737
+ await manager.stop();
729
738
  });
730
739
  });
731
740
 
@@ -839,6 +848,19 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
839
848
  auto_commit: false,
840
849
  auto_push: false,
841
850
  });
851
+ // Mirror the in-memory deps config into the REAL per-vault file:
852
+ // select-repo now runs maybeEnableHistoryOnLink (PR #484 fold), whose
853
+ // never-configured branch keys off the file's existence — without it
854
+ // the helper would see "never configured" and clobber this test's
855
+ // config with defaults.
856
+ writeMirrorConfigForVault("default", {
857
+ ...defaultMirrorConfig(),
858
+ enabled: true,
859
+ location: "internal",
860
+ sync_mode: "manual",
861
+ auto_commit: false,
862
+ auto_push: false,
863
+ });
842
864
  await manager.start();
843
865
  expect(manager.getConfig().auto_push).toBe(false);
844
866
 
@@ -900,6 +922,14 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
900
922
  enabled: false,
901
923
  auto_push: false,
902
924
  });
925
+ // Real file too — select-repo's history-on-link (PR #484 fold) must
926
+ // see this as an EXPLICIT enabled:false (left_disabled), not as a
927
+ // never-configured vault it should enable.
928
+ writeMirrorConfigForVault("default", {
929
+ ...defaultMirrorConfig(),
930
+ enabled: false,
931
+ auto_push: false,
932
+ });
903
933
  await manager.start();
904
934
  writeCredentials("default", {
905
935
  active_method: "github_oauth",
@@ -984,14 +1014,14 @@ describe("auth credential routes — github repos / create-repo", () => {
984
1014
  expect(res.status).toBe(400);
985
1015
  });
986
1016
 
987
- test("repos returns list when authed", async () => {
1017
+ test("repos lists from the installation (vault#480) — installed:true + account annotation", async () => {
988
1018
  home = tmp("mirror-auth-repos-ok-");
989
1019
  const { manager } = makeManager(home);
990
1020
  writeCredentials("default", {
991
1021
  active_method: "github_oauth",
992
1022
  github_oauth: {
993
- access_token: "gho_test1234567890",
994
- scope: "repo",
1023
+ access_token: "ghu_test1234567890",
1024
+ scope: "",
995
1025
  authorized_at: "2026-05-28T03:14:15.000Z",
996
1026
  user_login: "aaron",
997
1027
  user_id: 1,
@@ -1000,26 +1030,280 @@ describe("auth credential routes — github repos / create-repo", () => {
1000
1030
  });
1001
1031
  const fetcher = buildMockFetch([
1002
1032
  {
1003
- match: (u) => u.includes("/user/repos"),
1004
- body: [
1005
- {
1006
- name: "a",
1007
- full_name: "aaron/a",
1008
- private: true,
1009
- html_url: "https://github.com/aaron/a",
1010
- description: null,
1011
- updated_at: "2026-05-28T00:00:00Z",
1012
- clone_url: "https://github.com/aaron/a.git",
1013
- owner: { login: "aaron" },
1014
- },
1015
- ],
1033
+ match: (u) => u.includes("/user/installations?"),
1034
+ body: {
1035
+ total_count: 1,
1036
+ installations: [
1037
+ {
1038
+ id: 101,
1039
+ app_slug: "parachute-computer",
1040
+ account: { login: "aaron", type: "User" },
1041
+ repository_selection: "selected",
1042
+ },
1043
+ ],
1044
+ },
1045
+ },
1046
+ {
1047
+ match: (u) => u.includes("/user/installations/101/repositories"),
1048
+ body: {
1049
+ total_count: 1,
1050
+ repositories: [
1051
+ {
1052
+ name: "a",
1053
+ full_name: "aaron/a",
1054
+ private: true,
1055
+ html_url: "https://github.com/aaron/a",
1056
+ description: null,
1057
+ updated_at: "2026-05-28T00:00:00Z",
1058
+ clone_url: "https://github.com/aaron/a.git",
1059
+ owner: { login: "aaron" },
1060
+ },
1061
+ ],
1062
+ },
1016
1063
  },
1017
1064
  ]);
1018
1065
  const res = await handleAuthGithubRepos(manager, fetcher);
1019
1066
  expect(res.status).toBe(200);
1020
- const body = (await res.json()) as { repos: Array<{ full_name: string }> };
1067
+ const body = (await res.json()) as {
1068
+ installed: boolean;
1069
+ truncated: boolean;
1070
+ repos: Array<{ full_name: string; account_login: string; installation_id: number }>;
1071
+ };
1072
+ expect(body.installed).toBe(true);
1073
+ expect(body.truncated).toBe(false);
1021
1074
  expect(body.repos).toHaveLength(1);
1022
1075
  expect(body.repos[0]!.full_name).toBe("aaron/a");
1076
+ expect(body.repos[0]!.account_login).toBe("aaron");
1077
+ expect(body.repos[0]!.installation_id).toBe(101);
1078
+ });
1079
+
1080
+ test("repos unions multiple installations (user + org) with per-repo annotation", async () => {
1081
+ // The org-repos blind spot Aaron hit live: GET /user/repos?type=owner
1082
+ // excluded org-owned repos by construction. The installations source
1083
+ // enumerates both.
1084
+ home = tmp("mirror-auth-repos-multi-");
1085
+ const { manager } = makeManager(home);
1086
+ writeCredentials("default", {
1087
+ active_method: "github_oauth",
1088
+ github_oauth: {
1089
+ access_token: "ghu_test1234567890",
1090
+ scope: "",
1091
+ authorized_at: "2026-05-28T03:14:15.000Z",
1092
+ user_login: "aaron",
1093
+ user_id: 1,
1094
+ },
1095
+ pat: null,
1096
+ });
1097
+ const repoItem = (owner: string, name: string) => ({
1098
+ name,
1099
+ full_name: `${owner}/${name}`,
1100
+ private: true,
1101
+ html_url: `https://github.com/${owner}/${name}`,
1102
+ description: null,
1103
+ updated_at: "2026-06-10T00:00:00Z",
1104
+ clone_url: `https://github.com/${owner}/${name}.git`,
1105
+ owner: { login: owner },
1106
+ });
1107
+ const fetcher = buildMockFetch([
1108
+ {
1109
+ match: (u) => u.includes("/user/installations?"),
1110
+ body: {
1111
+ total_count: 2,
1112
+ installations: [
1113
+ {
1114
+ id: 101,
1115
+ app_slug: "parachute-computer",
1116
+ account: { login: "aaron", type: "User" },
1117
+ repository_selection: "selected",
1118
+ },
1119
+ {
1120
+ id: 202,
1121
+ app_slug: "parachute-computer",
1122
+ account: { login: "unforced-org", type: "Organization" },
1123
+ repository_selection: "selected",
1124
+ },
1125
+ ],
1126
+ },
1127
+ },
1128
+ {
1129
+ match: (u) => u.includes("/user/installations/101/repositories"),
1130
+ body: { total_count: 1, repositories: [repoItem("aaron", "personal-vault")] },
1131
+ },
1132
+ {
1133
+ match: (u) => u.includes("/user/installations/202/repositories"),
1134
+ body: { total_count: 1, repositories: [repoItem("unforced-org", "team-vault")] },
1135
+ },
1136
+ ]);
1137
+ const res = await handleAuthGithubRepos(manager, fetcher);
1138
+ expect(res.status).toBe(200);
1139
+ const body = (await res.json()) as {
1140
+ installed: boolean;
1141
+ repos: Array<{ full_name: string; account_login: string; installation_id: number }>;
1142
+ };
1143
+ expect(body.installed).toBe(true);
1144
+ expect(body.repos).toHaveLength(2);
1145
+ expect(body.repos[0]!.full_name).toBe("aaron/personal-vault");
1146
+ expect(body.repos[0]!.account_login).toBe("aaron");
1147
+ expect(body.repos[0]!.installation_id).toBe(101);
1148
+ expect(body.repos[1]!.full_name).toBe("unforced-org/team-vault");
1149
+ expect(body.repos[1]!.account_login).toBe("unforced-org");
1150
+ expect(body.repos[1]!.installation_id).toBe(202);
1151
+ });
1152
+
1153
+ test("repos are sorted most-recently-updated first within each account group", async () => {
1154
+ // The installation-repositories endpoint has no `sort` param (the old
1155
+ // GET /user/repos?sort=updated source did) — the handler sorts each
1156
+ // group itself so the repo the operator probably wants is near the top.
1157
+ home = tmp("mirror-auth-repos-sorted-");
1158
+ const { manager } = makeManager(home);
1159
+ writeCredentials("default", {
1160
+ active_method: "github_oauth",
1161
+ github_oauth: {
1162
+ access_token: "ghu_test1234567890",
1163
+ scope: "",
1164
+ authorized_at: "2026-05-28T03:14:15.000Z",
1165
+ user_login: "aaron",
1166
+ user_id: 1,
1167
+ },
1168
+ pat: null,
1169
+ });
1170
+ const repoItem = (name: string, updated_at: string) => ({
1171
+ name,
1172
+ full_name: `aaron/${name}`,
1173
+ private: true,
1174
+ html_url: `https://github.com/aaron/${name}`,
1175
+ description: null,
1176
+ updated_at,
1177
+ clone_url: `https://github.com/aaron/${name}.git`,
1178
+ owner: { login: "aaron" },
1179
+ });
1180
+ const fetcher = buildMockFetch([
1181
+ {
1182
+ match: (u) => u.includes("/user/installations?"),
1183
+ body: {
1184
+ total_count: 1,
1185
+ installations: [
1186
+ {
1187
+ id: 101,
1188
+ app_slug: "parachute-computer",
1189
+ account: { login: "aaron", type: "User" },
1190
+ repository_selection: "all",
1191
+ },
1192
+ ],
1193
+ },
1194
+ },
1195
+ {
1196
+ match: (u) => u.includes("/user/installations/101/repositories"),
1197
+ body: {
1198
+ total_count: 3,
1199
+ // Alphabetical wire order (GitHub's default) — NOT recency.
1200
+ repositories: [
1201
+ repoItem("alpha", "2026-01-01T00:00:00Z"),
1202
+ repoItem("beta", "2026-06-09T00:00:00Z"),
1203
+ repoItem("gamma", "2026-03-15T00:00:00Z"),
1204
+ ],
1205
+ },
1206
+ },
1207
+ ]);
1208
+ const res = await handleAuthGithubRepos(manager, fetcher);
1209
+ expect(res.status).toBe(200);
1210
+ const body = (await res.json()) as { repos: Array<{ name: string }> };
1211
+ expect(body.repos.map((r) => r.name)).toEqual(["beta", "gamma", "alpha"]);
1212
+ });
1213
+
1214
+ test("repos returns the machine-readable not_installed state when authorized but not installed", async () => {
1215
+ home = tmp("mirror-auth-repos-notinstalled-");
1216
+ const { manager } = makeManager(home);
1217
+ writeCredentials("default", {
1218
+ active_method: "github_oauth",
1219
+ github_oauth: {
1220
+ access_token: "ghu_test1234567890",
1221
+ scope: "",
1222
+ authorized_at: "2026-05-28T03:14:15.000Z",
1223
+ user_login: "aaron",
1224
+ user_id: 1,
1225
+ },
1226
+ pat: null,
1227
+ });
1228
+ const fetcher = buildMockFetch([
1229
+ {
1230
+ match: (u) => u.includes("/user/installations?"),
1231
+ body: { total_count: 0, installations: [] },
1232
+ },
1233
+ ]);
1234
+ const res = await handleAuthGithubRepos(manager, fetcher);
1235
+ expect(res.status).toBe(200);
1236
+ const body = (await res.json()) as {
1237
+ installed: boolean;
1238
+ install_url: string;
1239
+ repos: unknown[];
1240
+ truncated: boolean;
1241
+ };
1242
+ expect(body.installed).toBe(false);
1243
+ expect(body.install_url).toBe(
1244
+ "https://github.com/apps/parachute-computer/installations/new",
1245
+ );
1246
+ expect(body.repos).toEqual([]);
1247
+ expect(body.truncated).toBe(false);
1248
+ });
1249
+
1250
+ test("repos carries truncated:true when an installation hits the page cap", async () => {
1251
+ home = tmp("mirror-auth-repos-trunc-");
1252
+ const { manager } = makeManager(home);
1253
+ writeCredentials("default", {
1254
+ active_method: "github_oauth",
1255
+ github_oauth: {
1256
+ access_token: "ghu_test1234567890",
1257
+ scope: "",
1258
+ authorized_at: "2026-05-28T03:14:15.000Z",
1259
+ user_login: "aaron",
1260
+ user_id: 1,
1261
+ },
1262
+ pat: null,
1263
+ });
1264
+ // The route calls listInstallationRepos with defaults (perPage=100,
1265
+ // maxPages=3): serve 3 FULL pages of 100 so the cap trips.
1266
+ const fullPage = (page: number) => ({
1267
+ total_count: 1000,
1268
+ repositories: Array.from({ length: 100 }, (_, i) => {
1269
+ const n = page * 100 + i;
1270
+ return {
1271
+ name: `r${n}`,
1272
+ full_name: `aaron/r${n}`,
1273
+ private: false,
1274
+ html_url: `https://github.com/aaron/r${n}`,
1275
+ description: null,
1276
+ updated_at: "2026-06-10T00:00:00Z",
1277
+ clone_url: `https://github.com/aaron/r${n}.git`,
1278
+ owner: { login: "aaron" },
1279
+ };
1280
+ }),
1281
+ });
1282
+ const fetcher = buildMockFetch([
1283
+ {
1284
+ match: (u) => u.includes("/user/installations?"),
1285
+ body: {
1286
+ total_count: 1,
1287
+ installations: [
1288
+ {
1289
+ id: 101,
1290
+ app_slug: "parachute-computer",
1291
+ account: { login: "aaron", type: "User" },
1292
+ repository_selection: "all",
1293
+ },
1294
+ ],
1295
+ },
1296
+ },
1297
+ { match: (u) => u.includes("/repositories") && u.includes("page=1"), body: fullPage(0) },
1298
+ { match: (u) => u.includes("/repositories") && u.includes("page=2"), body: fullPage(1) },
1299
+ { match: (u) => u.includes("/repositories") && u.includes("page=3"), body: fullPage(2) },
1300
+ ]);
1301
+ const res = await handleAuthGithubRepos(manager, fetcher);
1302
+ expect(res.status).toBe(200);
1303
+ const body = (await res.json()) as { installed: boolean; truncated: boolean; repos: unknown[] };
1304
+ expect(body.installed).toBe(true);
1305
+ expect(body.truncated).toBe(true);
1306
+ expect(body.repos).toHaveLength(300);
1023
1307
  });
1024
1308
 
1025
1309
  test("create-repo proxies through with mocked fetch", async () => {
@@ -1061,6 +1345,481 @@ describe("auth credential routes — github repos / create-repo", () => {
1061
1345
  const body = (await res.json()) as { full_name: string };
1062
1346
  expect(body.full_name).toBe("aaron/new-vault");
1063
1347
  });
1348
+
1349
+ test("create-repo maps GitHub's 403 to app_lacks_admin_permission + the guided-manual path (vault#480)", async () => {
1350
+ // Expected with the shared Contents-only app: POST /user/repos needs
1351
+ // Administration:write. The response must be actionable + machine-
1352
+ // readable, not a generic 502.
1353
+ home = tmp("mirror-auth-create-repo-403-");
1354
+ const { manager } = makeManager(home);
1355
+ writeCredentials("default", {
1356
+ active_method: "github_oauth",
1357
+ github_oauth: {
1358
+ access_token: "ghu_test1234567890",
1359
+ scope: "",
1360
+ authorized_at: "2026-05-28T03:14:15.000Z",
1361
+ user_login: "aaron",
1362
+ user_id: 1,
1363
+ },
1364
+ pat: null,
1365
+ });
1366
+ const fetcher = buildMockFetch([
1367
+ {
1368
+ match: (u) => u.includes("/user/repos"),
1369
+ status: 403,
1370
+ body: { message: "Resource not accessible by integration" },
1371
+ },
1372
+ ]);
1373
+ const req = new Request("http://x/create", {
1374
+ method: "POST",
1375
+ body: JSON.stringify({ name: "new-vault" }),
1376
+ });
1377
+ const res = await handleAuthGithubCreateRepo(req, manager, fetcher);
1378
+ expect(res.status).toBe(403);
1379
+ const body = (await res.json()) as { error_type: string; message: string };
1380
+ expect(body.error_type).toBe("app_lacks_admin_permission");
1381
+ // Names the guided-manual path: create at github.com/new → add to the
1382
+ // installation → refresh.
1383
+ expect(body.message).toContain("github.com/new");
1384
+ expect(body.message).toContain("installation");
1385
+ });
1386
+ });
1387
+
1388
+ // ---------------------------------------------------------------------------
1389
+ // GET /.parachute/mirror/auth/github/installations — install state (vault#480).
1390
+ // ---------------------------------------------------------------------------
1391
+
1392
+ describe("auth credential routes — install state (GET /auth/github/installations)", () => {
1393
+ let home: string;
1394
+ afterEach(() => {
1395
+ if (home) fs.rmSync(home, { recursive: true, force: true });
1396
+ delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
1397
+ delete process.env.PARACHUTE_GITHUB_APP_SLUG;
1398
+ });
1399
+
1400
+ const oauthCreds = (): MirrorCredentials => ({
1401
+ active_method: "github_oauth",
1402
+ github_oauth: {
1403
+ access_token: "ghu_test1234567890",
1404
+ scope: "",
1405
+ authorized_at: "2026-06-10T00:00:00.000Z",
1406
+ user_login: "aaron",
1407
+ user_id: 1,
1408
+ },
1409
+ pat: null,
1410
+ });
1411
+
1412
+ test("400 github_not_connected when no github_oauth credential exists", async () => {
1413
+ // 400, not 401, matching the sibling repos handler — a 401 would trip
1414
+ // the SPA's authedFetch token-refresh machinery (clearing a valid
1415
+ // cached admin token) over a condition that isn't an auth failure.
1416
+ home = tmp("mirror-installstate-nocred-");
1417
+ const { manager } = makeManager(home);
1418
+ const res = await handleAuthGithubInstallations(manager);
1419
+ expect(res.status).toBe(400);
1420
+ const body = (await res.json()) as { error_type: string; message: string };
1421
+ expect(body.error_type).toBe("github_not_connected");
1422
+ expect(body.message).toContain("device flow");
1423
+ });
1424
+
1425
+ test("authorized but not installed → installed:false + install_url, empty installations", async () => {
1426
+ home = tmp("mirror-installstate-notinstalled-");
1427
+ const { manager } = makeManager(home);
1428
+ writeCredentials("default", oauthCreds());
1429
+ const fetcher = buildMockFetch([
1430
+ {
1431
+ match: (u) => u.includes("/user/installations?"),
1432
+ body: { total_count: 0, installations: [] },
1433
+ },
1434
+ ]);
1435
+ const res = await handleAuthGithubInstallations(manager, fetcher);
1436
+ expect(res.status).toBe(200);
1437
+ const body = (await res.json()) as {
1438
+ app: { client_id: string; slug: string; is_shared_default: boolean };
1439
+ installed: boolean;
1440
+ install_url: string;
1441
+ installations: unknown[];
1442
+ };
1443
+ expect(body.installed).toBe(false);
1444
+ expect(body.installations).toEqual([]);
1445
+ expect(body.install_url).toBe(
1446
+ "https://github.com/apps/parachute-computer/installations/new",
1447
+ );
1448
+ expect(body.app.slug).toBe("parachute-computer");
1449
+ expect(body.app.client_id).toBe("Iv23livaRF4VcvPhu3uB");
1450
+ expect(body.app.is_shared_default).toBe(true);
1451
+ });
1452
+
1453
+ test("installed on a user account AND an org → both surfaced", async () => {
1454
+ home = tmp("mirror-installstate-installed-");
1455
+ const { manager } = makeManager(home);
1456
+ writeCredentials("default", oauthCreds());
1457
+ const fetcher = buildMockFetch([
1458
+ {
1459
+ match: (u) => u.includes("/user/installations?"),
1460
+ body: {
1461
+ total_count: 2,
1462
+ installations: [
1463
+ {
1464
+ id: 101,
1465
+ app_slug: "parachute-computer",
1466
+ account: { login: "aaron", type: "User" },
1467
+ repository_selection: "selected",
1468
+ },
1469
+ {
1470
+ id: 202,
1471
+ app_slug: "parachute-computer",
1472
+ account: { login: "unforced-org", type: "Organization" },
1473
+ repository_selection: "all",
1474
+ },
1475
+ ],
1476
+ },
1477
+ },
1478
+ ]);
1479
+ const res = await handleAuthGithubInstallations(manager, fetcher);
1480
+ expect(res.status).toBe(200);
1481
+ const body = (await res.json()) as {
1482
+ installed: boolean;
1483
+ installations: Array<{
1484
+ id: number;
1485
+ account_login: string;
1486
+ account_type: string;
1487
+ repository_selection: string;
1488
+ }>;
1489
+ };
1490
+ expect(body.installed).toBe(true);
1491
+ expect(body.installations).toHaveLength(2);
1492
+ expect(body.installations[0]).toEqual({
1493
+ id: 101,
1494
+ account_login: "aaron",
1495
+ account_type: "User",
1496
+ repository_selection: "selected",
1497
+ });
1498
+ expect(body.installations[1]).toEqual({
1499
+ id: 202,
1500
+ account_login: "unforced-org",
1501
+ account_type: "Organization",
1502
+ repository_selection: "all",
1503
+ });
1504
+ });
1505
+
1506
+ test("BYO-app env overrides are reflected in the app block (is_shared_default false)", async () => {
1507
+ home = tmp("mirror-installstate-byo-");
1508
+ const { manager } = makeManager(home);
1509
+ writeCredentials("default", oauthCreds());
1510
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.byoclient";
1511
+ process.env.PARACHUTE_GITHUB_APP_SLUG = "my-own-app";
1512
+ const fetcher = buildMockFetch([
1513
+ {
1514
+ match: (u) => u.includes("/user/installations?"),
1515
+ body: { total_count: 0, installations: [] },
1516
+ },
1517
+ ]);
1518
+ const res = await handleAuthGithubInstallations(manager, fetcher);
1519
+ expect(res.status).toBe(200);
1520
+ const body = (await res.json()) as {
1521
+ app: { client_id: string; slug: string; is_shared_default: boolean };
1522
+ install_url: string;
1523
+ };
1524
+ expect(body.app.client_id).toBe("Iv1.byoclient");
1525
+ expect(body.app.slug).toBe("my-own-app");
1526
+ expect(body.app.is_shared_default).toBe(false);
1527
+ expect(body.install_url).toBe("https://github.com/apps/my-own-app/installations/new");
1528
+ });
1529
+
1530
+ test("502 when GitHub is unreachable / errors", async () => {
1531
+ home = tmp("mirror-installstate-apierr-");
1532
+ const { manager } = makeManager(home);
1533
+ writeCredentials("default", oauthCreds());
1534
+ const fetcher = buildMockFetch([
1535
+ {
1536
+ match: (u) => u.includes("/user/installations?"),
1537
+ status: 401,
1538
+ body: { message: "Bad credentials" },
1539
+ },
1540
+ ]);
1541
+ const res = await handleAuthGithubInstallations(manager, fetcher);
1542
+ expect(res.status).toBe(502);
1543
+ const body = (await res.json()) as { error: string };
1544
+ expect(body.error).toBe("Installation check failed");
1545
+ });
1546
+ });
1547
+
1548
+ // ---------------------------------------------------------------------------
1549
+ // vault#483 fix 1 — history-on-link (credential save enables history for a
1550
+ // never-configured vault; explicit operator choices are respected).
1551
+ //
1552
+ // Managers here are wired to the REAL per-vault mirror-config file (the
1553
+ // makeSyncManager pattern) because the never-configured branch keys off the
1554
+ // file's existence — the in-memory makeManager deps would diverge from what
1555
+ // maybeEnableHistoryOnLink reads.
1556
+ // ---------------------------------------------------------------------------
1557
+
1558
+ describe("history-on-link (vault#483)", () => {
1559
+ let home: string;
1560
+ let manager: MirrorManager;
1561
+ afterEach(async () => {
1562
+ if (manager) await manager.stop();
1563
+ if (home) fs.rmSync(home, { recursive: true, force: true });
1564
+ _resetDeviceFlowSessionsForTest();
1565
+ });
1566
+
1567
+ /** Run the device flow to `granted` against a mocked GitHub. */
1568
+ async function grantDeviceFlow(mgr: MirrorManager): Promise<{
1569
+ state: string;
1570
+ history_enabled: true | false | "left_disabled";
1571
+ }> {
1572
+ const fetchCode = buildMockFetch([
1573
+ {
1574
+ match: (u) => u.includes("/login/device/code"),
1575
+ body: {
1576
+ device_code: "dev_xyz",
1577
+ user_code: "ABCD-1234",
1578
+ verification_uri: "https://github.com/login/device",
1579
+ expires_in: 900,
1580
+ interval: 5,
1581
+ },
1582
+ },
1583
+ ]);
1584
+ const codeRes = await handleAuthGithubDeviceCode(fetchCode);
1585
+ expect(codeRes.status).toBe(200);
1586
+ const { polling_id } = (await codeRes.json()) as { polling_id: string };
1587
+ const fetchGranted = buildMockFetch([
1588
+ {
1589
+ match: (u) => u.includes("/login/oauth/access_token"),
1590
+ body: { access_token: "ghu_granted1234567890", scope: "", token_type: "bearer" },
1591
+ },
1592
+ {
1593
+ match: (u) => u.includes("/user"),
1594
+ body: { login: "aaron", id: 12345, name: "Aaron G" },
1595
+ },
1596
+ ]);
1597
+ const grantRes = await handleAuthGithubPoll(
1598
+ new Request("http://x/poll", { method: "POST", body: JSON.stringify({ polling_id }) }),
1599
+ mgr,
1600
+ fetchGranted,
1601
+ );
1602
+ expect(grantRes.status).toBe(200);
1603
+ return (await grantRes.json()) as {
1604
+ state: string;
1605
+ history_enabled: true | false | "left_disabled";
1606
+ };
1607
+ }
1608
+
1609
+ test("device-flow grant on a never-configured vault enables history with the standard defaults", async () => {
1610
+ home = tmp("history-link-fresh-");
1611
+ manager = makeSyncManager(home);
1612
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
1613
+
1614
+ const body = await grantDeviceFlow(manager);
1615
+ expect(body.state).toBe("granted");
1616
+ expect(body.history_enabled).toBe(true);
1617
+
1618
+ // Config file now exists with the standard internal-mirror defaults.
1619
+ const cfg = readMirrorConfigForVault("default");
1620
+ expect(cfg?.enabled).toBe(true);
1621
+ expect(cfg?.location).toBe("internal");
1622
+ expect(cfg?.sync_mode).toBe("events");
1623
+ expect(cfg?.auto_commit).toBe(true);
1624
+ // auto_push stays OFF at link time — no repo picked yet; select-repo's
1625
+ // Cut 3 flips it once a remote exists.
1626
+ expect(cfg?.auto_push).toBe(false);
1627
+ // And the mirror actually started.
1628
+ expect(manager.getStatus().enabled).toBe(true);
1629
+ });
1630
+
1631
+ test("device-flow grant does NOT flip an explicitly-disabled mirror; flags left_disabled", async () => {
1632
+ home = tmp("history-link-disabled-");
1633
+ manager = makeSyncManager(home);
1634
+ writeMirrorConfigForVault("default", {
1635
+ ...defaultMirrorConfig(),
1636
+ enabled: false,
1637
+ });
1638
+
1639
+ const body = await grantDeviceFlow(manager);
1640
+ expect(body.state).toBe("granted");
1641
+ expect(body.history_enabled).toBe("left_disabled");
1642
+
1643
+ // The operator's explicit choice survives untouched.
1644
+ const cfg = readMirrorConfigForVault("default");
1645
+ expect(cfg?.enabled).toBe(false);
1646
+ expect(manager.getStatus().enabled).toBe(false);
1647
+ });
1648
+
1649
+ test("device-flow grant leaves an already-enabled mirror untouched (history_enabled true)", async () => {
1650
+ home = tmp("history-link-enabled-");
1651
+ manager = makeSyncManager(home);
1652
+ // Non-default field values so "untouched" is distinguishable from
1653
+ // "rewritten with defaults."
1654
+ writeMirrorConfigForVault("default", {
1655
+ ...defaultMirrorConfig(),
1656
+ enabled: true,
1657
+ sync_mode: "manual",
1658
+ auto_commit: false,
1659
+ safety_net_seconds: 120,
1660
+ });
1661
+
1662
+ const body = await grantDeviceFlow(manager);
1663
+ expect(body.state).toBe("granted");
1664
+ expect(body.history_enabled).toBe(true);
1665
+
1666
+ const cfg = readMirrorConfigForVault("default");
1667
+ expect(cfg?.enabled).toBe(true);
1668
+ expect(cfg?.sync_mode).toBe("manual");
1669
+ expect(cfg?.auto_commit).toBe(false);
1670
+ expect(cfg?.safety_net_seconds).toBe(120);
1671
+ });
1672
+
1673
+ test("PAT save on a never-configured vault enables history, then Cut 3 flips auto_push (remote known)", async () => {
1674
+ home = tmp("history-link-pat-fresh-");
1675
+ manager = makeSyncManager(home);
1676
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
1677
+
1678
+ // probeOverride bypasses the real `git ls-remote`; 127.0.0.1:1 keeps the
1679
+ // post-save initial push hermetic (instant connection refusal — never a
1680
+ // real GitHub round-trip).
1681
+ const res = await handleAuthPat(
1682
+ new Request("http://x/pat", {
1683
+ method: "POST",
1684
+ body: JSON.stringify({
1685
+ token: "ghp_link_test_token_123",
1686
+ remote_url: "https://127.0.0.1:1/owner/repo.git",
1687
+ }),
1688
+ }),
1689
+ manager,
1690
+ async () => ({ ok: true }),
1691
+ );
1692
+ expect(res.status).toBe(200);
1693
+ const body = (await res.json()) as {
1694
+ history_enabled: true | false | "left_disabled";
1695
+ auto_push_enabled: boolean;
1696
+ };
1697
+ expect(body.history_enabled).toBe(true);
1698
+ // PAT carries the remote, so the full chain runs: history on → Cut 3
1699
+ // flips auto_push on the now-enabled mirror.
1700
+ expect(body.auto_push_enabled).toBe(true);
1701
+ const cfg = readMirrorConfigForVault("default");
1702
+ expect(cfg?.enabled).toBe(true);
1703
+ expect(cfg?.auto_push).toBe(true);
1704
+ // No token leak.
1705
+ expect(JSON.stringify(body)).not.toContain("ghp_link_test_token_123");
1706
+ }, 30_000);
1707
+
1708
+ test("PAT save respects an explicitly-disabled mirror (left_disabled; auto_push untouched)", async () => {
1709
+ home = tmp("history-link-pat-disabled-");
1710
+ manager = makeSyncManager(home);
1711
+ writeMirrorConfigForVault("default", {
1712
+ ...defaultMirrorConfig(),
1713
+ enabled: false,
1714
+ });
1715
+
1716
+ const res = await handleAuthPat(
1717
+ new Request("http://x/pat", {
1718
+ method: "POST",
1719
+ body: JSON.stringify({
1720
+ token: "ghp_link_test_token_456",
1721
+ remote_url: "https://127.0.0.1:1/owner/repo.git",
1722
+ }),
1723
+ }),
1724
+ manager,
1725
+ async () => ({ ok: true }),
1726
+ );
1727
+ expect(res.status).toBe(200);
1728
+ const body = (await res.json()) as {
1729
+ history_enabled: true | false | "left_disabled";
1730
+ auto_push_enabled: boolean;
1731
+ };
1732
+ expect(body.history_enabled).toBe("left_disabled");
1733
+ expect(body.auto_push_enabled).toBe(false);
1734
+ const cfg = readMirrorConfigForVault("default");
1735
+ expect(cfg?.enabled).toBe(false);
1736
+ expect(cfg?.auto_push).toBe(false);
1737
+ });
1738
+
1739
+ /** Seed a stored github_oauth credential — the select-repo precondition.
1740
+ * Models a credential saved BEFORE history-on-link existed: the operator
1741
+ * re-enters via "Choose repository…" without a fresh grant. */
1742
+ function seedOauthCredential(): void {
1743
+ writeCredentials("default", {
1744
+ active_method: "github_oauth",
1745
+ github_oauth: {
1746
+ access_token: "gho_selectrepo1234567890",
1747
+ scope: "repo",
1748
+ authorized_at: "2026-06-10T00:00:00.000Z",
1749
+ user_login: "aaron",
1750
+ user_id: 1,
1751
+ },
1752
+ pat: null,
1753
+ });
1754
+ }
1755
+
1756
+ test("select-repo on a never-configured vault enables history + carries history_enabled (PR #484 fold)", async () => {
1757
+ // The #483 re-entry gap: a credential saved pre-history-on-link on a
1758
+ // never-configured vault. The grant/PAT paths never ran for this
1759
+ // config, so select-repo is the first linked action — it must wire
1760
+ // history (before auto_push, which no-ops on a disabled mirror).
1761
+ home = tmp("history-link-selectrepo-fresh-");
1762
+ manager = makeSyncManager(home);
1763
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
1764
+ seedOauthCredential();
1765
+ expect(readMirrorConfigForVault("default")).toBeUndefined();
1766
+
1767
+ const res = await handleAuthGithubSelectRepo(
1768
+ new Request("http://x/select", {
1769
+ method: "POST",
1770
+ body: JSON.stringify({ owner: "aaron", name: "my-vault" }),
1771
+ }),
1772
+ manager,
1773
+ );
1774
+ expect(res.status).toBe(200);
1775
+ const body = (await res.json()) as {
1776
+ ok: boolean;
1777
+ history_enabled: true | false | "left_disabled";
1778
+ auto_push_enabled: boolean;
1779
+ };
1780
+ expect(body.ok).toBe(true);
1781
+ expect(body.history_enabled).toBe(true);
1782
+ // History first, so the full chain runs: enabled mirror → Cut 3 flips
1783
+ // auto_push (the PAT handler's ordering, mirrored).
1784
+ expect(body.auto_push_enabled).toBe(true);
1785
+ const cfg = readMirrorConfigForVault("default");
1786
+ expect(cfg?.enabled).toBe(true);
1787
+ expect(cfg?.location).toBe("internal");
1788
+ expect(cfg?.auto_push).toBe(true);
1789
+ expect(manager.getStatus().enabled).toBe(true);
1790
+ // No token leak.
1791
+ expect(JSON.stringify(body)).not.toContain("gho_selectrepo1234567890");
1792
+ }, 30_000);
1793
+
1794
+ test("select-repo respects an explicitly-disabled mirror (left_disabled; stays off)", async () => {
1795
+ home = tmp("history-link-selectrepo-disabled-");
1796
+ manager = makeSyncManager(home);
1797
+ writeMirrorConfigForVault("default", {
1798
+ ...defaultMirrorConfig(),
1799
+ enabled: false,
1800
+ });
1801
+ seedOauthCredential();
1802
+
1803
+ const res = await handleAuthGithubSelectRepo(
1804
+ new Request("http://x/select", {
1805
+ method: "POST",
1806
+ body: JSON.stringify({ owner: "aaron", name: "my-vault" }),
1807
+ }),
1808
+ manager,
1809
+ );
1810
+ expect(res.status).toBe(200);
1811
+ const body = (await res.json()) as {
1812
+ history_enabled: true | false | "left_disabled";
1813
+ auto_push_enabled: boolean;
1814
+ };
1815
+ // The operator's explicit choice survives — the UI gets the one-click
1816
+ // "Turn on history now?" offer instead of a silent flip.
1817
+ expect(body.history_enabled).toBe("left_disabled");
1818
+ expect(body.auto_push_enabled).toBe(false);
1819
+ const cfg = readMirrorConfigForVault("default");
1820
+ expect(cfg?.enabled).toBe(false);
1821
+ expect(cfg?.auto_push).toBe(false);
1822
+ });
1064
1823
  });
1065
1824
 
1066
1825
  // ---------------------------------------------------------------------------