@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.
- package/README.md +31 -6
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +1118 -46
- package/src/mirror-routes.ts +405 -32
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/src/vault.test.ts +56 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
|
@@ -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
|
|
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
|
-
|
|
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,9 +1014,301 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
984
1014
|
expect(res.status).toBe(400);
|
|
985
1015
|
});
|
|
986
1016
|
|
|
987
|
-
test("repos
|
|
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);
|
|
1020
|
+
writeCredentials("default", {
|
|
1021
|
+
active_method: "github_oauth",
|
|
1022
|
+
github_oauth: {
|
|
1023
|
+
access_token: "ghu_test1234567890",
|
|
1024
|
+
scope: "",
|
|
1025
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
1026
|
+
user_login: "aaron",
|
|
1027
|
+
user_id: 1,
|
|
1028
|
+
},
|
|
1029
|
+
pat: null,
|
|
1030
|
+
});
|
|
1031
|
+
const fetcher = buildMockFetch([
|
|
1032
|
+
{
|
|
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
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
]);
|
|
1065
|
+
const res = await handleAuthGithubRepos(manager, fetcher);
|
|
1066
|
+
expect(res.status).toBe(200);
|
|
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);
|
|
1074
|
+
expect(body.repos).toHaveLength(1);
|
|
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);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("create-repo proxies through with mocked fetch", async () => {
|
|
1310
|
+
home = tmp("mirror-auth-create-repo-");
|
|
1311
|
+
const { manager } = makeManager(home);
|
|
990
1312
|
writeCredentials("default", {
|
|
991
1313
|
active_method: "github_oauth",
|
|
992
1314
|
github_oauth: {
|
|
@@ -1001,65 +1323,502 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
1001
1323
|
const fetcher = buildMockFetch([
|
|
1002
1324
|
{
|
|
1003
1325
|
match: (u) => u.includes("/user/repos"),
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
],
|
|
1326
|
+
status: 201,
|
|
1327
|
+
body: {
|
|
1328
|
+
name: "new-vault",
|
|
1329
|
+
full_name: "aaron/new-vault",
|
|
1330
|
+
private: true,
|
|
1331
|
+
html_url: "https://github.com/aaron/new-vault",
|
|
1332
|
+
description: "x",
|
|
1333
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
1334
|
+
clone_url: "https://github.com/aaron/new-vault.git",
|
|
1335
|
+
owner: { login: "aaron" },
|
|
1336
|
+
},
|
|
1016
1337
|
},
|
|
1017
1338
|
]);
|
|
1018
|
-
const
|
|
1339
|
+
const req = new Request("http://x/create", {
|
|
1340
|
+
method: "POST",
|
|
1341
|
+
body: JSON.stringify({ name: "new-vault" }),
|
|
1342
|
+
});
|
|
1343
|
+
const res = await handleAuthGithubCreateRepo(req, manager, fetcher);
|
|
1344
|
+
expect(res.status).toBe(200);
|
|
1345
|
+
const body = (await res.json()) as { full_name: string };
|
|
1346
|
+
expect(body.full_name).toBe("aaron/new-vault");
|
|
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
|
+
);
|
|
1019
1727
|
expect(res.status).toBe(200);
|
|
1020
|
-
const body = (await res.json()) as {
|
|
1021
|
-
|
|
1022
|
-
|
|
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);
|
|
1023
1737
|
});
|
|
1024
1738
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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 {
|
|
1028
1743
|
writeCredentials("default", {
|
|
1029
1744
|
active_method: "github_oauth",
|
|
1030
1745
|
github_oauth: {
|
|
1031
|
-
access_token: "
|
|
1746
|
+
access_token: "gho_selectrepo1234567890",
|
|
1032
1747
|
scope: "repo",
|
|
1033
|
-
authorized_at: "2026-
|
|
1748
|
+
authorized_at: "2026-06-10T00:00:00.000Z",
|
|
1034
1749
|
user_login: "aaron",
|
|
1035
1750
|
user_id: 1,
|
|
1036
1751
|
},
|
|
1037
1752
|
pat: null,
|
|
1038
1753
|
});
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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,
|
|
1058
1800
|
});
|
|
1059
|
-
|
|
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
|
+
);
|
|
1060
1810
|
expect(res.status).toBe(200);
|
|
1061
|
-
const body = (await res.json()) as {
|
|
1062
|
-
|
|
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);
|
|
1063
1822
|
});
|
|
1064
1823
|
});
|
|
1065
1824
|
|
|
@@ -1636,6 +2395,108 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
1636
2395
|
expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
|
|
1637
2396
|
});
|
|
1638
2397
|
|
|
2398
|
+
test("vault#482: import-sync to a repo a SIBLING vault already backs up → import succeeds but sync declined + conflict warning", async () => {
|
|
2399
|
+
home = tmp("import-sync-cross-vault-");
|
|
2400
|
+
await bootstrapVault(home); // creates vault "default"
|
|
2401
|
+
fixture = await buildExportFixture();
|
|
2402
|
+
manager = makeSyncManager(home);
|
|
2403
|
+
|
|
2404
|
+
// Sibling vault "family" already syncs to the repo we're importing.
|
|
2405
|
+
const sibDataDir = path.join(home, "vault", "data", "family");
|
|
2406
|
+
fs.mkdirSync(sibDataDir, { recursive: true });
|
|
2407
|
+
fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
|
|
2408
|
+
writeMirrorConfigForVault("family", {
|
|
2409
|
+
...defaultMirrorConfig(),
|
|
2410
|
+
enabled: true,
|
|
2411
|
+
location: "internal",
|
|
2412
|
+
});
|
|
2413
|
+
const sibGitDir = path.join(sibDataDir, "mirror", ".git");
|
|
2414
|
+
fs.mkdirSync(sibGitDir, { recursive: true });
|
|
2415
|
+
fs.writeFileSync(
|
|
2416
|
+
path.join(sibGitDir, "config"),
|
|
2417
|
+
'[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
|
|
2418
|
+
);
|
|
2419
|
+
|
|
2420
|
+
const req = new Request("http://x/import", {
|
|
2421
|
+
method: "POST",
|
|
2422
|
+
body: JSON.stringify({
|
|
2423
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2424
|
+
mode: "merge",
|
|
2425
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2426
|
+
enable_sync: true,
|
|
2427
|
+
}),
|
|
2428
|
+
});
|
|
2429
|
+
const res = await handleMirrorImport(
|
|
2430
|
+
req,
|
|
2431
|
+
"default",
|
|
2432
|
+
spawnCloneSuccess(fixture),
|
|
2433
|
+
undefined,
|
|
2434
|
+
manager,
|
|
2435
|
+
);
|
|
2436
|
+
expect(res.status).toBe(200);
|
|
2437
|
+
const body = (await res.json()) as {
|
|
2438
|
+
notes_imported: number;
|
|
2439
|
+
sync_enabled: boolean;
|
|
2440
|
+
sync_warning?: string;
|
|
2441
|
+
};
|
|
2442
|
+
// Import itself never fails on the conflict.
|
|
2443
|
+
expect(body.notes_imported).toBe(2);
|
|
2444
|
+
// ...but sync is declined + the warning names the sibling vault + repo.
|
|
2445
|
+
expect(body.sync_enabled).toBe(false);
|
|
2446
|
+
expect(body.sync_warning).toContain("family");
|
|
2447
|
+
expect(body.sync_warning).toContain("github.com/aaron/my-vault");
|
|
2448
|
+
|
|
2449
|
+
// No sync config / credential got persisted for "default".
|
|
2450
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
2451
|
+
expect(readCredentials("default")).toBeNull();
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
test("vault#482: import-sync with override=true binds anyway despite a sibling on the same repo", async () => {
|
|
2455
|
+
home = tmp("import-sync-cross-override-");
|
|
2456
|
+
await bootstrapVault(home);
|
|
2457
|
+
fixture = await buildExportFixture();
|
|
2458
|
+
manager = makeSyncManager(home);
|
|
2459
|
+
|
|
2460
|
+
const sibDataDir = path.join(home, "vault", "data", "family");
|
|
2461
|
+
fs.mkdirSync(sibDataDir, { recursive: true });
|
|
2462
|
+
fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
|
|
2463
|
+
writeMirrorConfigForVault("family", {
|
|
2464
|
+
...defaultMirrorConfig(),
|
|
2465
|
+
enabled: true,
|
|
2466
|
+
location: "internal",
|
|
2467
|
+
});
|
|
2468
|
+
const sibGitDir = path.join(sibDataDir, "mirror", ".git");
|
|
2469
|
+
fs.mkdirSync(sibGitDir, { recursive: true });
|
|
2470
|
+
fs.writeFileSync(
|
|
2471
|
+
path.join(sibGitDir, "config"),
|
|
2472
|
+
'[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
|
|
2473
|
+
);
|
|
2474
|
+
|
|
2475
|
+
const req = new Request("http://x/import", {
|
|
2476
|
+
method: "POST",
|
|
2477
|
+
body: JSON.stringify({
|
|
2478
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2479
|
+
mode: "merge",
|
|
2480
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2481
|
+
enable_sync: true,
|
|
2482
|
+
override: true,
|
|
2483
|
+
}),
|
|
2484
|
+
});
|
|
2485
|
+
const res = await handleMirrorImport(
|
|
2486
|
+
req,
|
|
2487
|
+
"default",
|
|
2488
|
+
spawnCloneSuccess(fixture),
|
|
2489
|
+
undefined,
|
|
2490
|
+
manager,
|
|
2491
|
+
);
|
|
2492
|
+
expect(res.status).toBe(200);
|
|
2493
|
+
const body = (await res.json()) as { sync_enabled: boolean };
|
|
2494
|
+
expect(body.sync_enabled).toBe(true);
|
|
2495
|
+
// Sync config + credential persisted for "default" despite the sibling.
|
|
2496
|
+
expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
|
|
2497
|
+
expect(readCredentials("default")?.pat?.token).toBe("ghp_import_token_abc");
|
|
2498
|
+
});
|
|
2499
|
+
|
|
1639
2500
|
test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
|
|
1640
2501
|
home = tmp("import-sync-same-");
|
|
1641
2502
|
await bootstrapVault(home);
|
|
@@ -1796,3 +2657,214 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
1796
2657
|
});
|
|
1797
2658
|
});
|
|
1798
2659
|
|
|
2660
|
+
// ---------------------------------------------------------------------------
|
|
2661
|
+
// vault#482 — cross-vault remote-clobber guard at the ROUTE level.
|
|
2662
|
+
//
|
|
2663
|
+
// Two vaults on one server pointing their mirror at the same repo force-push
|
|
2664
|
+
// over each other's backups (silent data loss). The guard refuses a bind
|
|
2665
|
+
// (PAT save / OAuth repo pick / import-then-sync) when a SIBLING vault already
|
|
2666
|
+
// claims the same normalized remote, unless `override: true`. Re-binding a
|
|
2667
|
+
// vault to its OWN remote is always allowed (no false positive).
|
|
2668
|
+
// ---------------------------------------------------------------------------
|
|
2669
|
+
|
|
2670
|
+
/**
|
|
2671
|
+
* Seed a sibling vault on disk under the active PARACHUTE_HOME so
|
|
2672
|
+
* `listVaults()` sees it, with an enabled internal mirror whose `origin`
|
|
2673
|
+
* points at `originUrl`. No git spawn — we write `.git/config` directly,
|
|
2674
|
+
* exactly what the guard reads.
|
|
2675
|
+
*/
|
|
2676
|
+
function seedSiblingVault(name: string, originUrl: string): void {
|
|
2677
|
+
const home = process.env.PARACHUTE_HOME!;
|
|
2678
|
+
const dataDir = path.join(home, "vault", "data", name);
|
|
2679
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
2680
|
+
fs.writeFileSync(path.join(dataDir, "vault.yaml"), `name: ${name}\n`);
|
|
2681
|
+
writeMirrorConfigForVault(name, {
|
|
2682
|
+
...defaultMirrorConfig(),
|
|
2683
|
+
enabled: true,
|
|
2684
|
+
location: "internal",
|
|
2685
|
+
});
|
|
2686
|
+
const mirrorGitDir = path.join(dataDir, "mirror", ".git");
|
|
2687
|
+
fs.mkdirSync(mirrorGitDir, { recursive: true });
|
|
2688
|
+
fs.writeFileSync(
|
|
2689
|
+
path.join(mirrorGitDir, "config"),
|
|
2690
|
+
`[remote "origin"]\n\turl = ${originUrl}\n`,
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
describe("cross-vault remote-clobber guard (vault#482)", () => {
|
|
2695
|
+
let home: string;
|
|
2696
|
+
afterEach(() => {
|
|
2697
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
test("PAT: a second vault targeting a sibling's repo is refused with 409 + helpful error", async () => {
|
|
2701
|
+
home = tmp("guard-pat-refuse-");
|
|
2702
|
+
const { manager } = makeManager(home); // vault "default"
|
|
2703
|
+
// Sibling vault "family" already backs up to aaron/shared.
|
|
2704
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2705
|
+
|
|
2706
|
+
const res = await handleAuthPat(
|
|
2707
|
+
new Request("http://x/pat", {
|
|
2708
|
+
method: "POST",
|
|
2709
|
+
body: JSON.stringify({
|
|
2710
|
+
token: "ghp_newtoken1234567890",
|
|
2711
|
+
remote_url: "https://github.com/aaron/shared.git",
|
|
2712
|
+
}),
|
|
2713
|
+
}),
|
|
2714
|
+
manager,
|
|
2715
|
+
// Probe stub — the conflict guard runs AFTER the probe, so the probe
|
|
2716
|
+
// must pass for us to reach (and assert on) the guard.
|
|
2717
|
+
async () => ({ ok: true }),
|
|
2718
|
+
);
|
|
2719
|
+
expect(res.status).toBe(409);
|
|
2720
|
+
const body = (await res.json()) as {
|
|
2721
|
+
error_type: string;
|
|
2722
|
+
conflicting_vault: string;
|
|
2723
|
+
remote: string;
|
|
2724
|
+
message: string;
|
|
2725
|
+
};
|
|
2726
|
+
expect(body.error_type).toBe("remote_conflict");
|
|
2727
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2728
|
+
expect(body.remote).toBe("github.com/aaron/shared");
|
|
2729
|
+
// Names the vault + repo + tells the operator how to proceed.
|
|
2730
|
+
expect(body.message).toContain("family");
|
|
2731
|
+
expect(body.message).toContain("github.com/aaron/shared");
|
|
2732
|
+
expect(body.message).toContain("override");
|
|
2733
|
+
// Nothing got persisted for the refused vault.
|
|
2734
|
+
expect(readCredentials("default")).toBeNull();
|
|
2735
|
+
});
|
|
2736
|
+
|
|
2737
|
+
test("PAT: URL-normalization — sibling stored as scp-shorthand, candidate https → recognized as same repo", async () => {
|
|
2738
|
+
home = tmp("guard-pat-norm-");
|
|
2739
|
+
const { manager } = makeManager(home);
|
|
2740
|
+
// Sibling stored its origin as SSH scp-shorthand.
|
|
2741
|
+
seedSiblingVault("family", "git@github.com:aaron/shared.git");
|
|
2742
|
+
|
|
2743
|
+
const res = await handleAuthPat(
|
|
2744
|
+
new Request("http://x/pat", {
|
|
2745
|
+
method: "POST",
|
|
2746
|
+
body: JSON.stringify({
|
|
2747
|
+
token: "ghp_newtoken1234567890",
|
|
2748
|
+
// ...candidate arrives as HTTPS without .git.
|
|
2749
|
+
remote_url: "https://github.com/aaron/shared",
|
|
2750
|
+
}),
|
|
2751
|
+
}),
|
|
2752
|
+
manager,
|
|
2753
|
+
async () => ({ ok: true }),
|
|
2754
|
+
);
|
|
2755
|
+
expect(res.status).toBe(409);
|
|
2756
|
+
const body = (await res.json()) as { conflicting_vault: string };
|
|
2757
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
test("PAT: re-pointing the SAME vault at its OWN remote is allowed (no false positive)", async () => {
|
|
2761
|
+
home = tmp("guard-pat-self-");
|
|
2762
|
+
const { manager } = makeManager(home); // vault "default"
|
|
2763
|
+
// "default" already targets aaron/mine (its own origin) + a sibling
|
|
2764
|
+
// targets a DIFFERENT repo — neither should block a self re-bind.
|
|
2765
|
+
seedSiblingVault("default", "https://github.com/aaron/mine.git");
|
|
2766
|
+
seedSiblingVault("family", "https://github.com/aaron/theirs.git");
|
|
2767
|
+
|
|
2768
|
+
const res = await handleAuthPat(
|
|
2769
|
+
new Request("http://x/pat", {
|
|
2770
|
+
method: "POST",
|
|
2771
|
+
body: JSON.stringify({
|
|
2772
|
+
token: "ghp_rotated1234567890",
|
|
2773
|
+
// Same repo "default" already points at — token rotation.
|
|
2774
|
+
remote_url: "https://github.com/aaron/mine.git",
|
|
2775
|
+
}),
|
|
2776
|
+
}),
|
|
2777
|
+
manager,
|
|
2778
|
+
async () => ({ ok: true }),
|
|
2779
|
+
);
|
|
2780
|
+
expect(res.status).toBe(200);
|
|
2781
|
+
const creds = readCredentials("default");
|
|
2782
|
+
expect(creds?.active_method).toBe("pat");
|
|
2783
|
+
expect(creds?.pat?.remote_url).toContain("github.com/aaron/mine.git");
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
test("PAT: override=true bypasses the guard and saves anyway", async () => {
|
|
2787
|
+
home = tmp("guard-pat-override-");
|
|
2788
|
+
const { manager } = makeManager(home);
|
|
2789
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2790
|
+
|
|
2791
|
+
const res = await handleAuthPat(
|
|
2792
|
+
new Request("http://x/pat", {
|
|
2793
|
+
method: "POST",
|
|
2794
|
+
body: JSON.stringify({
|
|
2795
|
+
token: "ghp_newtoken1234567890",
|
|
2796
|
+
remote_url: "https://github.com/aaron/shared.git",
|
|
2797
|
+
override: true,
|
|
2798
|
+
}),
|
|
2799
|
+
}),
|
|
2800
|
+
manager,
|
|
2801
|
+
async () => ({ ok: true }),
|
|
2802
|
+
);
|
|
2803
|
+
expect(res.status).toBe(200);
|
|
2804
|
+
expect(readCredentials("default")?.active_method).toBe("pat");
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
test("select-repo: picking a sibling's repo is refused with 409", async () => {
|
|
2808
|
+
home = tmp("guard-selectrepo-refuse-");
|
|
2809
|
+
const { manager } = makeManager(home);
|
|
2810
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2811
|
+
// "default" has an OAuth credential (select-repo requires one).
|
|
2812
|
+
writeCredentials("default", {
|
|
2813
|
+
active_method: "github_oauth",
|
|
2814
|
+
github_oauth: {
|
|
2815
|
+
access_token: "gho_fake1234567890abcd",
|
|
2816
|
+
scope: "repo",
|
|
2817
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
2818
|
+
user_login: "aaron",
|
|
2819
|
+
user_id: 1,
|
|
2820
|
+
},
|
|
2821
|
+
pat: null,
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
const res = await handleAuthGithubSelectRepo(
|
|
2825
|
+
new Request("http://x/select", {
|
|
2826
|
+
method: "POST",
|
|
2827
|
+
body: JSON.stringify({ owner: "aaron", name: "shared" }),
|
|
2828
|
+
}),
|
|
2829
|
+
manager,
|
|
2830
|
+
);
|
|
2831
|
+
expect(res.status).toBe(409);
|
|
2832
|
+
const body = (await res.json()) as {
|
|
2833
|
+
error_type: string;
|
|
2834
|
+
conflicting_vault: string;
|
|
2835
|
+
};
|
|
2836
|
+
expect(body.error_type).toBe("remote_conflict");
|
|
2837
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
test("select-repo: override=true bypasses the guard", async () => {
|
|
2841
|
+
home = tmp("guard-selectrepo-override-");
|
|
2842
|
+
const { manager } = makeManager(home);
|
|
2843
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2844
|
+
writeCredentials("default", {
|
|
2845
|
+
active_method: "github_oauth",
|
|
2846
|
+
github_oauth: {
|
|
2847
|
+
access_token: "gho_fake1234567890abcd",
|
|
2848
|
+
scope: "repo",
|
|
2849
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
2850
|
+
user_login: "aaron",
|
|
2851
|
+
user_id: 1,
|
|
2852
|
+
},
|
|
2853
|
+
pat: null,
|
|
2854
|
+
});
|
|
2855
|
+
const res = await handleAuthGithubSelectRepo(
|
|
2856
|
+
new Request("http://x/select", {
|
|
2857
|
+
method: "POST",
|
|
2858
|
+
body: JSON.stringify({ owner: "aaron", name: "shared", override: true }),
|
|
2859
|
+
}),
|
|
2860
|
+
manager,
|
|
2861
|
+
);
|
|
2862
|
+
// 200 (or any non-409) — the guard didn't block it. (mirror_path is
|
|
2863
|
+
// unresolved since the manager never started, so it stores creds + returns
|
|
2864
|
+
// ok without applying to a remote.)
|
|
2865
|
+
expect(res.status).not.toBe(409);
|
|
2866
|
+
const body = (await res.json()) as { ok?: boolean };
|
|
2867
|
+
expect(body.ok).toBe(true);
|
|
2868
|
+
});
|
|
2869
|
+
});
|
|
2870
|
+
|