@openparachute/vault 0.6.0-rc.1 → 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.
- package/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -11,7 +11,12 @@ import fs from "node:fs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
defaultMirrorConfig,
|
|
16
|
+
readMirrorConfigForVault,
|
|
17
|
+
writeMirrorConfigForVault,
|
|
18
|
+
type MirrorConfig,
|
|
19
|
+
} from "./mirror-config.ts";
|
|
15
20
|
import {
|
|
16
21
|
MirrorManager,
|
|
17
22
|
type MirrorDeps,
|
|
@@ -22,6 +27,7 @@ import {
|
|
|
22
27
|
handleAuthGet,
|
|
23
28
|
handleAuthGithubCreateRepo,
|
|
24
29
|
handleAuthGithubDeviceCode,
|
|
30
|
+
handleAuthGithubInstallations,
|
|
25
31
|
handleAuthGithubPoll,
|
|
26
32
|
handleAuthGithubRepos,
|
|
27
33
|
handleAuthGithubSelectRepo,
|
|
@@ -245,6 +251,37 @@ describe("handleMirrorPut", () => {
|
|
|
245
251
|
}
|
|
246
252
|
});
|
|
247
253
|
|
|
254
|
+
test("external + git not installed → 503 git_not_installed + actionable message", async () => {
|
|
255
|
+
// vault#415 nit — handleMirrorPut validates the external path via
|
|
256
|
+
// validateExternalPath, which shells `git`. On a git-less server it must
|
|
257
|
+
// return the friendly 503 (consistent with the import route), not let a
|
|
258
|
+
// raw "Executable not found" crash out. Force the preflight via the
|
|
259
|
+
// whichOverride seam against a REAL git repo so the only failure is the
|
|
260
|
+
// preflight.
|
|
261
|
+
home = tmp("mirror-put-nogit-installed-");
|
|
262
|
+
const { manager } = makeManager(home);
|
|
263
|
+
const external = tmp("mirror-put-nogit-target-");
|
|
264
|
+
initRepo(external);
|
|
265
|
+
try {
|
|
266
|
+
const req = new Request("http://x/admin/mirror", {
|
|
267
|
+
method: "PUT",
|
|
268
|
+
body: JSON.stringify({
|
|
269
|
+
enabled: true,
|
|
270
|
+
location: "external",
|
|
271
|
+
external_path: external,
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
const res = await handleMirrorPut(req, manager, () => null);
|
|
275
|
+
expect(res.status).toBe(503);
|
|
276
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
277
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
278
|
+
expect(body.message).toContain("git is required");
|
|
279
|
+
expect(body.message).toContain("dnf install git");
|
|
280
|
+
} finally {
|
|
281
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
248
285
|
test("accepts a valid external config, persists, restarts watch", async () => {
|
|
249
286
|
home = tmp("mirror-put-happy-");
|
|
250
287
|
const external = tmp("mirror-put-ext-");
|
|
@@ -581,10 +618,12 @@ describe("auth credential routes — device flow", () => {
|
|
|
581
618
|
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
582
619
|
});
|
|
583
620
|
|
|
584
|
-
test("device-code returns 503
|
|
621
|
+
test("device-code returns 503 when the env override is placeholder-shaped", async () => {
|
|
585
622
|
home = tmp("mirror-auth-placeholder-");
|
|
586
623
|
makeManager(home);
|
|
587
|
-
|
|
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";
|
|
588
627
|
const res = await handleAuthGithubDeviceCode();
|
|
589
628
|
expect(res.status).toBe(503);
|
|
590
629
|
const body = (await res.json()) as { error_type: string };
|
|
@@ -690,6 +729,12 @@ describe("auth credential routes — device flow", () => {
|
|
|
690
729
|
expect(saved?.active_method).toBe("github_oauth");
|
|
691
730
|
expect(saved?.github_oauth?.access_token).toBe("gho_real1234567890");
|
|
692
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();
|
|
693
738
|
});
|
|
694
739
|
});
|
|
695
740
|
|
|
@@ -803,6 +848,19 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
803
848
|
auto_commit: false,
|
|
804
849
|
auto_push: false,
|
|
805
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
|
+
});
|
|
806
864
|
await manager.start();
|
|
807
865
|
expect(manager.getConfig().auto_push).toBe(false);
|
|
808
866
|
|
|
@@ -864,6 +922,14 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
864
922
|
enabled: false,
|
|
865
923
|
auto_push: false,
|
|
866
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
|
+
});
|
|
867
933
|
await manager.start();
|
|
868
934
|
writeCredentials("default", {
|
|
869
935
|
active_method: "github_oauth",
|
|
@@ -948,14 +1014,14 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
948
1014
|
expect(res.status).toBe(400);
|
|
949
1015
|
});
|
|
950
1016
|
|
|
951
|
-
test("repos
|
|
1017
|
+
test("repos lists from the installation (vault#480) — installed:true + account annotation", async () => {
|
|
952
1018
|
home = tmp("mirror-auth-repos-ok-");
|
|
953
1019
|
const { manager } = makeManager(home);
|
|
954
1020
|
writeCredentials("default", {
|
|
955
1021
|
active_method: "github_oauth",
|
|
956
1022
|
github_oauth: {
|
|
957
|
-
access_token: "
|
|
958
|
-
scope: "
|
|
1023
|
+
access_token: "ghu_test1234567890",
|
|
1024
|
+
scope: "",
|
|
959
1025
|
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
960
1026
|
user_login: "aaron",
|
|
961
1027
|
user_id: 1,
|
|
@@ -964,26 +1030,280 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
964
1030
|
});
|
|
965
1031
|
const fetcher = buildMockFetch([
|
|
966
1032
|
{
|
|
967
|
-
match: (u) => u.includes("/user/
|
|
968
|
-
body:
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
+
},
|
|
980
1063
|
},
|
|
981
1064
|
]);
|
|
982
1065
|
const res = await handleAuthGithubRepos(manager, fetcher);
|
|
983
1066
|
expect(res.status).toBe(200);
|
|
984
|
-
const body = (await res.json()) as {
|
|
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);
|
|
985
1074
|
expect(body.repos).toHaveLength(1);
|
|
986
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);
|
|
987
1307
|
});
|
|
988
1308
|
|
|
989
1309
|
test("create-repo proxies through with mocked fetch", async () => {
|
|
@@ -1025,111 +1345,608 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
1025
1345
|
const body = (await res.json()) as { full_name: string };
|
|
1026
1346
|
expect(body.full_name).toBe("aaron/new-vault");
|
|
1027
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
|
+
});
|
|
1028
1386
|
});
|
|
1029
1387
|
|
|
1030
1388
|
// ---------------------------------------------------------------------------
|
|
1031
|
-
//
|
|
1389
|
+
// GET /.parachute/mirror/auth/github/installations — install state (vault#480).
|
|
1032
1390
|
// ---------------------------------------------------------------------------
|
|
1033
1391
|
|
|
1034
|
-
|
|
1035
|
-
* Bootstrap a real vault config + db file so getVaultStore('default')
|
|
1036
|
-
* succeeds inside the handler. Returns the home dir for cleanup.
|
|
1037
|
-
*/
|
|
1038
|
-
async function bootstrapVault(home: string): Promise<void> {
|
|
1039
|
-
process.env.PARACHUTE_HOME = home;
|
|
1040
|
-
process.env.HOME = home;
|
|
1041
|
-
// The minimal layout vault needs to spin up its store: a per-vault
|
|
1042
|
-
// dir at $PARACHUTE_HOME/vault/data/<name> + a `vault.yaml` config.
|
|
1043
|
-
// writeVaultConfig creates these for us.
|
|
1044
|
-
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
1045
|
-
writeVaultConfig({
|
|
1046
|
-
name: "default",
|
|
1047
|
-
description: "import-test vault",
|
|
1048
|
-
created_at: "2026-05-28T00:00:00.000Z",
|
|
1049
|
-
api_keys: [],
|
|
1050
|
-
});
|
|
1051
|
-
clearVaultStoreCache();
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
/**
|
|
1055
|
-
* Build a real portable-md vault export, return the path. The clone
|
|
1056
|
-
* spawn-mock copies this into the tempdir to simulate a successful
|
|
1057
|
-
* `git clone`.
|
|
1058
|
-
*/
|
|
1059
|
-
async function buildExportFixture(): Promise<string> {
|
|
1060
|
-
const fixture = tmp("import-route-fixture-");
|
|
1061
|
-
const exportStore = new SqliteStore(new Database(":memory:"));
|
|
1062
|
-
await exportStore.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
|
|
1063
|
-
await exportStore.createNote("beta body", { id: "n-beta", path: "beta" });
|
|
1064
|
-
await exportVaultToDir(exportStore, {
|
|
1065
|
-
outDir: fixture,
|
|
1066
|
-
vaultName: "source",
|
|
1067
|
-
exportedAt: "2026-05-28T00:00:00.000Z",
|
|
1068
|
-
});
|
|
1069
|
-
return fixture;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
const spawnCloneSuccess = (fixture: string): GitSpawn => async (argv) => {
|
|
1073
|
-
const destDir = argv[argv.length - 1]!;
|
|
1074
|
-
cpSync(fixture, destDir, { recursive: true });
|
|
1075
|
-
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
const spawnCloneFail: GitSpawn = async () => ({
|
|
1079
|
-
exitCode: 128,
|
|
1080
|
-
stderr: "fatal: repository not found",
|
|
1081
|
-
timedOut: false,
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
describe("handleMirrorImport", () => {
|
|
1392
|
+
describe("auth credential routes — install state (GET /auth/github/installations)", () => {
|
|
1085
1393
|
let home: string;
|
|
1086
|
-
let fixture: string;
|
|
1087
|
-
|
|
1088
1394
|
afterEach(() => {
|
|
1089
1395
|
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
clearVaultStoreCache();
|
|
1396
|
+
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
1397
|
+
delete process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
1093
1398
|
});
|
|
1094
1399
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
expect(body.error_type).toBe("invalid_json");
|
|
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,
|
|
1106
1410
|
});
|
|
1107
1411
|
|
|
1108
|
-
test("
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
const res = await handleMirrorImport(req, "default");
|
|
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);
|
|
1116
1419
|
expect(res.status).toBe(400);
|
|
1117
|
-
const body = (await res.json()) as {
|
|
1118
|
-
expect(body.
|
|
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");
|
|
1119
1423
|
});
|
|
1120
1424
|
|
|
1121
|
-
test("
|
|
1122
|
-
home = tmp("
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
+
});
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
// ---------------------------------------------------------------------------
|
|
1826
|
+
// POST /.parachute/mirror/import — clone-and-import HTTP route (vault#391).
|
|
1827
|
+
// ---------------------------------------------------------------------------
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Bootstrap a real vault config + db file so getVaultStore('default')
|
|
1831
|
+
* succeeds inside the handler. Returns the home dir for cleanup.
|
|
1832
|
+
*/
|
|
1833
|
+
async function bootstrapVault(home: string): Promise<void> {
|
|
1834
|
+
process.env.PARACHUTE_HOME = home;
|
|
1835
|
+
process.env.HOME = home;
|
|
1836
|
+
// The minimal layout vault needs to spin up its store: a per-vault
|
|
1837
|
+
// dir at $PARACHUTE_HOME/vault/data/<name> + a `vault.yaml` config.
|
|
1838
|
+
// writeVaultConfig creates these for us.
|
|
1839
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
1840
|
+
writeVaultConfig({
|
|
1841
|
+
name: "default",
|
|
1842
|
+
description: "import-test vault",
|
|
1843
|
+
created_at: "2026-05-28T00:00:00.000Z",
|
|
1844
|
+
api_keys: [],
|
|
1845
|
+
});
|
|
1846
|
+
clearVaultStoreCache();
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Build a real portable-md vault export, return the path. The clone
|
|
1851
|
+
* spawn-mock copies this into the tempdir to simulate a successful
|
|
1852
|
+
* `git clone`.
|
|
1853
|
+
*/
|
|
1854
|
+
async function buildExportFixture(): Promise<string> {
|
|
1855
|
+
const fixture = tmp("import-route-fixture-");
|
|
1856
|
+
const exportStore = new SqliteStore(new Database(":memory:"));
|
|
1857
|
+
await exportStore.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
|
|
1858
|
+
await exportStore.createNote("beta body", { id: "n-beta", path: "beta" });
|
|
1859
|
+
await exportVaultToDir(exportStore, {
|
|
1860
|
+
outDir: fixture,
|
|
1861
|
+
vaultName: "source",
|
|
1862
|
+
exportedAt: "2026-05-28T00:00:00.000Z",
|
|
1863
|
+
});
|
|
1864
|
+
return fixture;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const spawnCloneSuccess = (fixture: string): GitSpawn => async (argv) => {
|
|
1868
|
+
const destDir = argv[argv.length - 1]!;
|
|
1869
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
1870
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
const spawnCloneFail: GitSpawn = async () => ({
|
|
1874
|
+
exitCode: 128,
|
|
1875
|
+
stderr: "fatal: repository not found",
|
|
1876
|
+
timedOut: false,
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* vault#416 — a MirrorManager wired to the REAL per-vault config file (so
|
|
1881
|
+
* `handleMirrorImport`'s `readMirrorConfigForVault` agrees with what
|
|
1882
|
+
* `manager.reload()` wrote) + a no-op export. Passed to `handleMirrorImport`
|
|
1883
|
+
* as the `managerOverride` so the sync-enable step has a live manager without
|
|
1884
|
+
* standing up the registry factory. Bootstrap (git init of the internal
|
|
1885
|
+
* mirror) runs for real; push to a fake remote fails non-fatally (we assert
|
|
1886
|
+
* on persisted config + credentials, not on a landed push).
|
|
1887
|
+
*/
|
|
1888
|
+
function makeSyncManager(home: string): MirrorManager {
|
|
1889
|
+
process.env.PARACHUTE_HOME = home;
|
|
1890
|
+
process.env.HOME = home;
|
|
1891
|
+
const deps: MirrorDeps = {
|
|
1892
|
+
vaultName: "default",
|
|
1893
|
+
runExport: async () => ({ notes: 0 }),
|
|
1894
|
+
firstChangedNoteTitle: async () => "",
|
|
1895
|
+
readMirrorConfig: () => readMirrorConfigForVault("default"),
|
|
1896
|
+
writeMirrorConfig: (c) => writeMirrorConfigForVault("default", c),
|
|
1897
|
+
};
|
|
1898
|
+
return new MirrorManager(deps);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
describe("handleMirrorImport", () => {
|
|
1902
|
+
let home: string;
|
|
1903
|
+
let fixture: string;
|
|
1904
|
+
|
|
1905
|
+
afterEach(() => {
|
|
1906
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
1907
|
+
if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
|
|
1908
|
+
_resetImportInFlightForTest();
|
|
1909
|
+
clearVaultStoreCache();
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
test("rejects invalid JSON body with 400", async () => {
|
|
1913
|
+
home = tmp("import-route-badjson-");
|
|
1914
|
+
await bootstrapVault(home);
|
|
1915
|
+
const req = new Request("http://x/import", {
|
|
1916
|
+
method: "POST",
|
|
1917
|
+
body: "{not-json",
|
|
1918
|
+
});
|
|
1919
|
+
const res = await handleMirrorImport(req, "default");
|
|
1920
|
+
expect(res.status).toBe(400);
|
|
1921
|
+
const body = (await res.json()) as { error_type: string };
|
|
1922
|
+
expect(body.error_type).toBe("invalid_json");
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
test("rejects missing remote_url with 400", async () => {
|
|
1926
|
+
home = tmp("import-route-no-url-");
|
|
1927
|
+
await bootstrapVault(home);
|
|
1928
|
+
const req = new Request("http://x/import", {
|
|
1929
|
+
method: "POST",
|
|
1930
|
+
body: JSON.stringify({ mode: "merge" }),
|
|
1931
|
+
});
|
|
1932
|
+
const res = await handleMirrorImport(req, "default");
|
|
1933
|
+
expect(res.status).toBe(400);
|
|
1934
|
+
const body = (await res.json()) as { field: string };
|
|
1935
|
+
expect(body.field).toBe("remote_url");
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("rejects invalid mode with 400", async () => {
|
|
1939
|
+
home = tmp("import-route-bad-mode-");
|
|
1940
|
+
await bootstrapVault(home);
|
|
1941
|
+
const req = new Request("http://x/import", {
|
|
1942
|
+
method: "POST",
|
|
1943
|
+
body: JSON.stringify({ remote_url: "https://github.com/a/b.git", mode: "wipe" }),
|
|
1944
|
+
});
|
|
1945
|
+
const res = await handleMirrorImport(req, "default");
|
|
1946
|
+
expect(res.status).toBe(400);
|
|
1947
|
+
const body = (await res.json()) as { field: string };
|
|
1948
|
+
expect(body.field).toBe("mode");
|
|
1949
|
+
});
|
|
1133
1950
|
|
|
1134
1951
|
test("rejects per-call PAT without token", async () => {
|
|
1135
1952
|
home = tmp("import-route-pat-missing-token-");
|
|
@@ -1258,6 +2075,35 @@ describe("handleMirrorImport", () => {
|
|
|
1258
2075
|
expect(body.message).toContain("vault.yaml");
|
|
1259
2076
|
});
|
|
1260
2077
|
|
|
2078
|
+
test("git not installed returns 503 + git_not_installed + actionable message", async () => {
|
|
2079
|
+
// vault#415 — live bug on a git-less Amazon Linux EC2 box. Force the
|
|
2080
|
+
// preflight (via the whichOverride seam) to see no git; the spawn seam
|
|
2081
|
+
// should never be reached.
|
|
2082
|
+
home = tmp("import-route-nogit-");
|
|
2083
|
+
await bootstrapVault(home);
|
|
2084
|
+
let spawnCalled = false;
|
|
2085
|
+
const spyingSpawn: GitSpawn = async () => {
|
|
2086
|
+
spawnCalled = true;
|
|
2087
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
2088
|
+
};
|
|
2089
|
+
const req = new Request("http://x/import", {
|
|
2090
|
+
method: "POST",
|
|
2091
|
+
body: JSON.stringify({
|
|
2092
|
+
remote_url: "https://github.com/a/b.git",
|
|
2093
|
+
mode: "merge",
|
|
2094
|
+
credentials: { kind: "none" },
|
|
2095
|
+
}),
|
|
2096
|
+
});
|
|
2097
|
+
const res = await handleMirrorImport(req, "default", spyingSpawn, () => null);
|
|
2098
|
+
expect(res.status).toBe(503);
|
|
2099
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
2100
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
2101
|
+
expect(body.message).toContain("git is required");
|
|
2102
|
+
expect(body.message).toContain("dnf install git");
|
|
2103
|
+
// Failed fast: the git spawn was never reached.
|
|
2104
|
+
expect(spawnCalled).toBe(false);
|
|
2105
|
+
});
|
|
2106
|
+
|
|
1261
2107
|
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1262
2108
|
home = tmp("import-route-stored-creds-");
|
|
1263
2109
|
await bootstrapVault(home);
|
|
@@ -1334,3 +2180,378 @@ describe("handleMirrorImport", () => {
|
|
|
1334
2180
|
});
|
|
1335
2181
|
});
|
|
1336
2182
|
|
|
2183
|
+
// ---------------------------------------------------------------------------
|
|
2184
|
+
// vault#416 — auto-enable sync to the imported repo (default-on, opt-out).
|
|
2185
|
+
// ---------------------------------------------------------------------------
|
|
2186
|
+
|
|
2187
|
+
describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
2188
|
+
let home: string;
|
|
2189
|
+
let fixture: string;
|
|
2190
|
+
let manager: MirrorManager;
|
|
2191
|
+
|
|
2192
|
+
afterEach(async () => {
|
|
2193
|
+
if (manager) await manager.stop();
|
|
2194
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
2195
|
+
if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
|
|
2196
|
+
_resetImportInFlightForTest();
|
|
2197
|
+
clearVaultStoreCache();
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
test("enable_sync true + PAT auth → mirror configured, creds persisted, auto_push on, sync_enabled true", async () => {
|
|
2201
|
+
home = tmp("import-sync-pat-");
|
|
2202
|
+
await bootstrapVault(home);
|
|
2203
|
+
fixture = await buildExportFixture();
|
|
2204
|
+
manager = makeSyncManager(home);
|
|
2205
|
+
|
|
2206
|
+
const req = new Request("http://x/import", {
|
|
2207
|
+
method: "POST",
|
|
2208
|
+
body: JSON.stringify({
|
|
2209
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2210
|
+
mode: "merge",
|
|
2211
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2212
|
+
enable_sync: true,
|
|
2213
|
+
}),
|
|
2214
|
+
});
|
|
2215
|
+
const res = await handleMirrorImport(
|
|
2216
|
+
req,
|
|
2217
|
+
"default",
|
|
2218
|
+
spawnCloneSuccess(fixture),
|
|
2219
|
+
undefined,
|
|
2220
|
+
manager,
|
|
2221
|
+
);
|
|
2222
|
+
expect(res.status).toBe(200);
|
|
2223
|
+
const body = (await res.json()) as {
|
|
2224
|
+
notes_imported: number;
|
|
2225
|
+
sync_enabled: boolean;
|
|
2226
|
+
sync_warning?: string;
|
|
2227
|
+
};
|
|
2228
|
+
expect(body.notes_imported).toBe(2);
|
|
2229
|
+
expect(body.sync_enabled).toBe(true);
|
|
2230
|
+
expect(body.sync_warning).toBeUndefined();
|
|
2231
|
+
|
|
2232
|
+
// Mirror config persisted with auto_push + enabled.
|
|
2233
|
+
const cfg = readMirrorConfigForVault("default");
|
|
2234
|
+
expect(cfg?.enabled).toBe(true);
|
|
2235
|
+
expect(cfg?.auto_push).toBe(true);
|
|
2236
|
+
|
|
2237
|
+
// Credentials persisted, pointing at the imported remote.
|
|
2238
|
+
const creds = readCredentials("default");
|
|
2239
|
+
expect(creds?.active_method).toBe("pat");
|
|
2240
|
+
expect(creds?.pat?.token).toBe("ghp_import_token_abc");
|
|
2241
|
+
expect(creds?.pat?.remote_url).toContain("github.com/aaron/my-vault.git");
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
test("enable_sync false → no mirror configured, sync_enabled false, no warning", async () => {
|
|
2245
|
+
home = tmp("import-sync-optout-");
|
|
2246
|
+
await bootstrapVault(home);
|
|
2247
|
+
fixture = await buildExportFixture();
|
|
2248
|
+
manager = makeSyncManager(home);
|
|
2249
|
+
|
|
2250
|
+
const req = new Request("http://x/import", {
|
|
2251
|
+
method: "POST",
|
|
2252
|
+
body: JSON.stringify({
|
|
2253
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2254
|
+
mode: "merge",
|
|
2255
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2256
|
+
enable_sync: false,
|
|
2257
|
+
}),
|
|
2258
|
+
});
|
|
2259
|
+
const res = await handleMirrorImport(
|
|
2260
|
+
req,
|
|
2261
|
+
"default",
|
|
2262
|
+
spawnCloneSuccess(fixture),
|
|
2263
|
+
undefined,
|
|
2264
|
+
manager,
|
|
2265
|
+
);
|
|
2266
|
+
expect(res.status).toBe(200);
|
|
2267
|
+
const body = (await res.json()) as {
|
|
2268
|
+
sync_enabled: boolean;
|
|
2269
|
+
sync_warning?: string;
|
|
2270
|
+
};
|
|
2271
|
+
expect(body.sync_enabled).toBe(false);
|
|
2272
|
+
expect(body.sync_warning).toBeUndefined();
|
|
2273
|
+
|
|
2274
|
+
// Nothing configured.
|
|
2275
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
2276
|
+
expect(readCredentials("default")).toBeNull();
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
test("enable_sync true + auth none → sync_enabled false + needs-write-creds warning; no broken mirror", async () => {
|
|
2280
|
+
home = tmp("import-sync-nocreds-");
|
|
2281
|
+
await bootstrapVault(home);
|
|
2282
|
+
fixture = await buildExportFixture();
|
|
2283
|
+
manager = makeSyncManager(home);
|
|
2284
|
+
|
|
2285
|
+
const req = new Request("http://x/import", {
|
|
2286
|
+
method: "POST",
|
|
2287
|
+
body: JSON.stringify({
|
|
2288
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2289
|
+
mode: "merge",
|
|
2290
|
+
credentials: { kind: "none" },
|
|
2291
|
+
enable_sync: true,
|
|
2292
|
+
}),
|
|
2293
|
+
});
|
|
2294
|
+
const res = await handleMirrorImport(
|
|
2295
|
+
req,
|
|
2296
|
+
"default",
|
|
2297
|
+
spawnCloneSuccess(fixture),
|
|
2298
|
+
undefined,
|
|
2299
|
+
manager,
|
|
2300
|
+
);
|
|
2301
|
+
expect(res.status).toBe(200);
|
|
2302
|
+
const body = (await res.json()) as {
|
|
2303
|
+
notes_imported: number;
|
|
2304
|
+
sync_enabled: boolean;
|
|
2305
|
+
sync_warning?: string;
|
|
2306
|
+
};
|
|
2307
|
+
// Import still succeeded.
|
|
2308
|
+
expect(body.notes_imported).toBe(2);
|
|
2309
|
+
expect(body.sync_enabled).toBe(false);
|
|
2310
|
+
expect(body.sync_warning).toContain("write credentials");
|
|
2311
|
+
|
|
2312
|
+
// No mirror left configured, no credentials written.
|
|
2313
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
2314
|
+
expect(readCredentials("default")).toBeNull();
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
test("enable_sync defaults to true when omitted", async () => {
|
|
2318
|
+
home = tmp("import-sync-default-");
|
|
2319
|
+
await bootstrapVault(home);
|
|
2320
|
+
fixture = await buildExportFixture();
|
|
2321
|
+
manager = makeSyncManager(home);
|
|
2322
|
+
|
|
2323
|
+
const req = new Request("http://x/import", {
|
|
2324
|
+
method: "POST",
|
|
2325
|
+
body: JSON.stringify({
|
|
2326
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2327
|
+
mode: "merge",
|
|
2328
|
+
credentials: { kind: "pat", token: "ghp_default_on_token" },
|
|
2329
|
+
// enable_sync omitted — should default ON.
|
|
2330
|
+
}),
|
|
2331
|
+
});
|
|
2332
|
+
const res = await handleMirrorImport(
|
|
2333
|
+
req,
|
|
2334
|
+
"default",
|
|
2335
|
+
spawnCloneSuccess(fixture),
|
|
2336
|
+
undefined,
|
|
2337
|
+
manager,
|
|
2338
|
+
);
|
|
2339
|
+
expect(res.status).toBe(200);
|
|
2340
|
+
const body = (await res.json()) as { sync_enabled: boolean };
|
|
2341
|
+
expect(body.sync_enabled).toBe(true);
|
|
2342
|
+
expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
test("existing mirror to a DIFFERENT remote → not clobbered, sync_enabled false + conflict warning", async () => {
|
|
2346
|
+
home = tmp("import-sync-conflict-");
|
|
2347
|
+
await bootstrapVault(home);
|
|
2348
|
+
fixture = await buildExportFixture();
|
|
2349
|
+
manager = makeSyncManager(home);
|
|
2350
|
+
|
|
2351
|
+
// Pre-existing mirror config (enabled) + credential pointing elsewhere.
|
|
2352
|
+
writeMirrorConfigForVault("default", {
|
|
2353
|
+
...defaultMirrorConfig(),
|
|
2354
|
+
enabled: true,
|
|
2355
|
+
auto_push: true,
|
|
2356
|
+
});
|
|
2357
|
+
writeCredentials("default", {
|
|
2358
|
+
active_method: "pat",
|
|
2359
|
+
github_oauth: null,
|
|
2360
|
+
pat: {
|
|
2361
|
+
token: "ghp_existing_other",
|
|
2362
|
+
remote_url:
|
|
2363
|
+
"https://x-access-token:ghp_existing_other@github.com/aaron/OTHER-repo.git",
|
|
2364
|
+
label: "existing backup",
|
|
2365
|
+
},
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
const req = new Request("http://x/import", {
|
|
2369
|
+
method: "POST",
|
|
2370
|
+
body: JSON.stringify({
|
|
2371
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2372
|
+
mode: "merge",
|
|
2373
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2374
|
+
enable_sync: true,
|
|
2375
|
+
}),
|
|
2376
|
+
});
|
|
2377
|
+
const res = await handleMirrorImport(
|
|
2378
|
+
req,
|
|
2379
|
+
"default",
|
|
2380
|
+
spawnCloneSuccess(fixture),
|
|
2381
|
+
undefined,
|
|
2382
|
+
manager,
|
|
2383
|
+
);
|
|
2384
|
+
expect(res.status).toBe(200);
|
|
2385
|
+
const body = (await res.json()) as {
|
|
2386
|
+
sync_enabled: boolean;
|
|
2387
|
+
sync_warning?: string;
|
|
2388
|
+
};
|
|
2389
|
+
expect(body.sync_enabled).toBe(false);
|
|
2390
|
+
expect(body.sync_warning).toContain("already syncs to a different repo");
|
|
2391
|
+
|
|
2392
|
+
// The existing credential was NOT clobbered.
|
|
2393
|
+
const creds = readCredentials("default");
|
|
2394
|
+
expect(creds?.pat?.token).toBe("ghp_existing_other");
|
|
2395
|
+
expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
|
|
2399
|
+
home = tmp("import-sync-same-");
|
|
2400
|
+
await bootstrapVault(home);
|
|
2401
|
+
fixture = await buildExportFixture();
|
|
2402
|
+
manager = makeSyncManager(home);
|
|
2403
|
+
|
|
2404
|
+
writeMirrorConfigForVault("default", {
|
|
2405
|
+
...defaultMirrorConfig(),
|
|
2406
|
+
enabled: true,
|
|
2407
|
+
auto_push: true,
|
|
2408
|
+
});
|
|
2409
|
+
writeCredentials("default", {
|
|
2410
|
+
active_method: "pat",
|
|
2411
|
+
github_oauth: null,
|
|
2412
|
+
pat: {
|
|
2413
|
+
token: "ghp_same_token",
|
|
2414
|
+
remote_url:
|
|
2415
|
+
"https://x-access-token:ghp_same_token@github.com/aaron/my-vault.git",
|
|
2416
|
+
label: "existing same",
|
|
2417
|
+
},
|
|
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_same_token" },
|
|
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
|
+
sync_enabled: boolean;
|
|
2439
|
+
sync_warning?: string;
|
|
2440
|
+
};
|
|
2441
|
+
expect(body.sync_enabled).toBe(true);
|
|
2442
|
+
expect(body.sync_warning).toBeUndefined();
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
test("existing GitHub-connected mirror → PAT import doesn't clobber it (sync_enabled false + warning)", async () => {
|
|
2446
|
+
home = tmp("import-sync-oauth-conflict-");
|
|
2447
|
+
await bootstrapVault(home);
|
|
2448
|
+
fixture = await buildExportFixture();
|
|
2449
|
+
manager = makeSyncManager(home);
|
|
2450
|
+
|
|
2451
|
+
writeMirrorConfigForVault("default", {
|
|
2452
|
+
...defaultMirrorConfig(),
|
|
2453
|
+
enabled: true,
|
|
2454
|
+
auto_push: true,
|
|
2455
|
+
});
|
|
2456
|
+
writeCredentials("default", {
|
|
2457
|
+
active_method: "github_oauth",
|
|
2458
|
+
github_oauth: {
|
|
2459
|
+
access_token: "gho_existing_oauth",
|
|
2460
|
+
scope: "repo",
|
|
2461
|
+
authorized_at: "2026-05-28T00:00:00.000Z",
|
|
2462
|
+
user_login: "aaron",
|
|
2463
|
+
user_id: 1,
|
|
2464
|
+
},
|
|
2465
|
+
pat: null,
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
const req = new Request("http://x/import", {
|
|
2469
|
+
method: "POST",
|
|
2470
|
+
body: JSON.stringify({
|
|
2471
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2472
|
+
mode: "merge",
|
|
2473
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2474
|
+
enable_sync: true,
|
|
2475
|
+
}),
|
|
2476
|
+
});
|
|
2477
|
+
const res = await handleMirrorImport(
|
|
2478
|
+
req,
|
|
2479
|
+
"default",
|
|
2480
|
+
spawnCloneSuccess(fixture),
|
|
2481
|
+
undefined,
|
|
2482
|
+
manager,
|
|
2483
|
+
);
|
|
2484
|
+
expect(res.status).toBe(200);
|
|
2485
|
+
const body = (await res.json()) as {
|
|
2486
|
+
sync_enabled: boolean;
|
|
2487
|
+
sync_warning?: string;
|
|
2488
|
+
};
|
|
2489
|
+
expect(body.sync_enabled).toBe(false);
|
|
2490
|
+
expect(body.sync_warning).toContain("connected GitHub account");
|
|
2491
|
+
|
|
2492
|
+
// The existing OAuth credential is untouched (not switched to a PAT).
|
|
2493
|
+
const creds = readCredentials("default");
|
|
2494
|
+
expect(creds?.active_method).toBe("github_oauth");
|
|
2495
|
+
expect(creds?.pat).toBeNull();
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
test("sync-setup failure after a successful import → import result still returned, sync_enabled false + warning", async () => {
|
|
2499
|
+
home = tmp("import-sync-setupfail-");
|
|
2500
|
+
await bootstrapVault(home);
|
|
2501
|
+
fixture = await buildExportFixture();
|
|
2502
|
+
manager = makeSyncManager(home);
|
|
2503
|
+
|
|
2504
|
+
// Force the sync-enable step to throw by stubbing the manager's reload.
|
|
2505
|
+
// The import itself has already succeeded by the time reload runs, so the
|
|
2506
|
+
// request must still return a 200 with the import counts intact.
|
|
2507
|
+
manager.reload = async () => {
|
|
2508
|
+
throw new Error("boom: simulated reload failure");
|
|
2509
|
+
};
|
|
2510
|
+
|
|
2511
|
+
const req = new Request("http://x/import", {
|
|
2512
|
+
method: "POST",
|
|
2513
|
+
body: JSON.stringify({
|
|
2514
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2515
|
+
mode: "merge",
|
|
2516
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2517
|
+
enable_sync: true,
|
|
2518
|
+
}),
|
|
2519
|
+
});
|
|
2520
|
+
const res = await handleMirrorImport(
|
|
2521
|
+
req,
|
|
2522
|
+
"default",
|
|
2523
|
+
spawnCloneSuccess(fixture),
|
|
2524
|
+
undefined,
|
|
2525
|
+
manager,
|
|
2526
|
+
);
|
|
2527
|
+
expect(res.status).toBe(200);
|
|
2528
|
+
const body = (await res.json()) as {
|
|
2529
|
+
notes_imported: number;
|
|
2530
|
+
sync_enabled: boolean;
|
|
2531
|
+
sync_warning?: string;
|
|
2532
|
+
};
|
|
2533
|
+
// Import NOT lost.
|
|
2534
|
+
expect(body.notes_imported).toBe(2);
|
|
2535
|
+
expect(body.sync_enabled).toBe(false);
|
|
2536
|
+
expect(body.sync_warning).toContain("enabling Sync failed");
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
test("invalid enable_sync type → 400 validation error", async () => {
|
|
2540
|
+
home = tmp("import-sync-badtype-");
|
|
2541
|
+
await bootstrapVault(home);
|
|
2542
|
+
const req = new Request("http://x/import", {
|
|
2543
|
+
method: "POST",
|
|
2544
|
+
body: JSON.stringify({
|
|
2545
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2546
|
+
mode: "merge",
|
|
2547
|
+
credentials: { kind: "none" },
|
|
2548
|
+
enable_sync: "yes",
|
|
2549
|
+
}),
|
|
2550
|
+
});
|
|
2551
|
+
const res = await handleMirrorImport(req, "default");
|
|
2552
|
+
expect(res.status).toBe(400);
|
|
2553
|
+
const body = (await res.json()) as { field: string };
|
|
2554
|
+
expect(body.field).toBe("enable_sync");
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
|