@openparachute/vault 0.7.4 → 0.7.5-rc.4

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.
@@ -34,6 +34,7 @@ import {
34
34
  handleAuthPat,
35
35
  handleMirrorGet,
36
36
  handleMirrorImport,
37
+ handleMirrorImportStatus,
37
38
  handleMirrorPushNow,
38
39
  handleMirrorPut,
39
40
  handleMirrorRunNow,
@@ -42,6 +43,7 @@ import {
42
43
  _resetImportInFlightForTest,
43
44
  type GitSpawn,
44
45
  } from "./mirror-import.ts";
46
+ import { _resetImportJobsForTest, type ImportJob } from "./mirror-import-jobs.ts";
45
47
  import { writeVaultConfig } from "./config.ts";
46
48
  import { clearVaultStoreCache } from "./vault-store.ts";
47
49
  import { exportVaultToDir } from "../core/src/portable-md.ts";
@@ -1984,6 +1986,7 @@ describe("handleMirrorImport", () => {
1984
1986
  if (home) fs.rmSync(home, { recursive: true, force: true });
1985
1987
  if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
1986
1988
  _resetImportInFlightForTest();
1989
+ _resetImportJobsForTest();
1987
1990
  clearVaultStoreCache();
1988
1991
  });
1989
1992
 
@@ -2034,6 +2037,7 @@ describe("handleMirrorImport", () => {
2034
2037
  body: JSON.stringify({
2035
2038
  remote_url: "https://github.com/a/b.git",
2036
2039
  mode: "merge",
2040
+ wait: true,
2037
2041
  credentials: { kind: "pat", token: "" },
2038
2042
  }),
2039
2043
  });
@@ -2051,6 +2055,7 @@ describe("handleMirrorImport", () => {
2051
2055
  body: JSON.stringify({
2052
2056
  remote_url: "https://github.com/a/b.git",
2053
2057
  mode: "merge",
2058
+ wait: true,
2054
2059
  credentials: { kind: "magic" },
2055
2060
  }),
2056
2061
  });
@@ -2069,6 +2074,7 @@ describe("handleMirrorImport", () => {
2069
2074
  body: JSON.stringify({
2070
2075
  remote_url: "https://github.com/a/b.git",
2071
2076
  mode: "merge",
2077
+ wait: true,
2072
2078
  credentials: { kind: "none" },
2073
2079
  }),
2074
2080
  });
@@ -2100,6 +2106,7 @@ describe("handleMirrorImport", () => {
2100
2106
  body: JSON.stringify({
2101
2107
  remote_url: "https://github.com/a/b.git",
2102
2108
  mode: "replace",
2109
+ wait: true,
2103
2110
  credentials: { kind: "none" },
2104
2111
  }),
2105
2112
  });
@@ -2121,6 +2128,7 @@ describe("handleMirrorImport", () => {
2121
2128
  body: JSON.stringify({
2122
2129
  remote_url: "https://github.com/a/b.git",
2123
2130
  mode: "merge",
2131
+ wait: true,
2124
2132
  credentials: { kind: "pat", token: "ghp_secret_xyz" },
2125
2133
  }),
2126
2134
  });
@@ -2143,6 +2151,7 @@ describe("handleMirrorImport", () => {
2143
2151
  body: JSON.stringify({
2144
2152
  remote_url: "https://github.com/a/b.git",
2145
2153
  mode: "merge",
2154
+ wait: true,
2146
2155
  credentials: { kind: "none" },
2147
2156
  }),
2148
2157
  });
@@ -2169,6 +2178,7 @@ describe("handleMirrorImport", () => {
2169
2178
  body: JSON.stringify({
2170
2179
  remote_url: "https://github.com/a/b.git",
2171
2180
  mode: "merge",
2181
+ wait: true,
2172
2182
  credentials: { kind: "none" },
2173
2183
  }),
2174
2184
  });
@@ -2210,6 +2220,7 @@ describe("handleMirrorImport", () => {
2210
2220
  body: JSON.stringify({
2211
2221
  remote_url: "https://github.com/a/b.git",
2212
2222
  mode: "merge",
2223
+ wait: true,
2213
2224
  credentials: null,
2214
2225
  }),
2215
2226
  });
@@ -2246,6 +2257,7 @@ describe("handleMirrorImport", () => {
2246
2257
  body: JSON.stringify({
2247
2258
  remote_url: "https://github.com/a/b.git",
2248
2259
  mode: "merge",
2260
+ wait: true,
2249
2261
  credentials: { kind: "pat", token: "ghp_per_call_only" },
2250
2262
  }),
2251
2263
  });
@@ -2272,6 +2284,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2272
2284
  if (home) fs.rmSync(home, { recursive: true, force: true });
2273
2285
  if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
2274
2286
  _resetImportInFlightForTest();
2287
+ _resetImportJobsForTest();
2275
2288
  clearVaultStoreCache();
2276
2289
  });
2277
2290
 
@@ -2286,6 +2299,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2286
2299
  body: JSON.stringify({
2287
2300
  remote_url: "https://github.com/aaron/my-vault.git",
2288
2301
  mode: "merge",
2302
+ wait: true,
2289
2303
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2290
2304
  enable_sync: true,
2291
2305
  }),
@@ -2330,6 +2344,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2330
2344
  body: JSON.stringify({
2331
2345
  remote_url: "https://github.com/aaron/my-vault.git",
2332
2346
  mode: "merge",
2347
+ wait: true,
2333
2348
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2334
2349
  enable_sync: false,
2335
2350
  }),
@@ -2365,6 +2380,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2365
2380
  body: JSON.stringify({
2366
2381
  remote_url: "https://github.com/aaron/my-vault.git",
2367
2382
  mode: "merge",
2383
+ wait: true,
2368
2384
  credentials: { kind: "none" },
2369
2385
  enable_sync: true,
2370
2386
  }),
@@ -2392,7 +2408,12 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2392
2408
  expect(readCredentials("default")).toBeNull();
2393
2409
  });
2394
2410
 
2395
- test("enable_sync defaults to true when omitted", async () => {
2411
+ // vault#641 the default INVERTED. Import is a read; it must not arm a
2412
+ // write to the source repo unless the operator says so. Pinned as its own
2413
+ // test because the old default (ON, vault#416) was the single most dangerous
2414
+ // thing about this flow: pulling a vault onto a new box silently repointed
2415
+ // backup at the repo you pulled from.
2416
+ test("enable_sync defaults to FALSE when omitted — import never arms push-back on its own", async () => {
2396
2417
  home = tmp("import-sync-default-");
2397
2418
  await bootstrapVault(home);
2398
2419
  fixture = await buildExportFixture();
@@ -2403,8 +2424,44 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2403
2424
  body: JSON.stringify({
2404
2425
  remote_url: "https://github.com/aaron/my-vault.git",
2405
2426
  mode: "merge",
2406
- credentials: { kind: "pat", token: "ghp_default_on_token" },
2407
- // enable_sync omitted should default ON.
2427
+ wait: true,
2428
+ credentials: { kind: "pat", token: "ghp_default_off_token" },
2429
+ // enable_sync omitted — must default OFF.
2430
+ }),
2431
+ });
2432
+ const res = await handleMirrorImport(
2433
+ req,
2434
+ "default",
2435
+ spawnCloneSuccess(fixture),
2436
+ undefined,
2437
+ manager,
2438
+ );
2439
+ expect(res.status).toBe(200);
2440
+ const body = (await res.json()) as {
2441
+ sync_enabled: boolean;
2442
+ sync_warning?: string;
2443
+ };
2444
+ expect(body.sync_enabled).toBe(false);
2445
+ // Not a failure — the operator didn't ask. No scary warning either.
2446
+ expect(body.sync_warning).toBeUndefined();
2447
+ // The decisive assertion: nothing was wired up.
2448
+ expect(readMirrorConfigForVault("default")?.auto_push ?? false).toBe(false);
2449
+ });
2450
+
2451
+ test("enable_sync: true still opts in explicitly", async () => {
2452
+ home = tmp("import-sync-optin-");
2453
+ await bootstrapVault(home);
2454
+ fixture = await buildExportFixture();
2455
+ manager = makeSyncManager(home);
2456
+
2457
+ const req = new Request("http://x/import", {
2458
+ method: "POST",
2459
+ body: JSON.stringify({
2460
+ remote_url: "https://github.com/aaron/my-vault.git",
2461
+ mode: "merge",
2462
+ wait: true,
2463
+ enable_sync: true,
2464
+ credentials: { kind: "pat", token: "ghp_opt_in_token" },
2408
2465
  }),
2409
2466
  });
2410
2467
  const res = await handleMirrorImport(
@@ -2448,6 +2505,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2448
2505
  body: JSON.stringify({
2449
2506
  remote_url: "https://github.com/aaron/my-vault.git",
2450
2507
  mode: "merge",
2508
+ wait: true,
2451
2509
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2452
2510
  enable_sync: true,
2453
2511
  }),
@@ -2500,6 +2558,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2500
2558
  body: JSON.stringify({
2501
2559
  remote_url: "https://github.com/aaron/my-vault.git",
2502
2560
  mode: "merge",
2561
+ wait: true,
2503
2562
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2504
2563
  enable_sync: true,
2505
2564
  }),
@@ -2555,6 +2614,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2555
2614
  body: JSON.stringify({
2556
2615
  remote_url: "https://github.com/aaron/my-vault.git",
2557
2616
  mode: "merge",
2617
+ wait: true,
2558
2618
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2559
2619
  enable_sync: true,
2560
2620
  override: true,
@@ -2602,6 +2662,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2602
2662
  body: JSON.stringify({
2603
2663
  remote_url: "https://github.com/aaron/my-vault.git",
2604
2664
  mode: "merge",
2665
+ wait: true,
2605
2666
  credentials: { kind: "pat", token: "ghp_same_token" },
2606
2667
  enable_sync: true,
2607
2668
  }),
@@ -2650,6 +2711,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2650
2711
  body: JSON.stringify({
2651
2712
  remote_url: "https://github.com/aaron/my-vault.git",
2652
2713
  mode: "merge",
2714
+ wait: true,
2653
2715
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2654
2716
  enable_sync: true,
2655
2717
  }),
@@ -2693,6 +2755,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2693
2755
  body: JSON.stringify({
2694
2756
  remote_url: "https://github.com/aaron/my-vault.git",
2695
2757
  mode: "merge",
2758
+ wait: true,
2696
2759
  credentials: { kind: "pat", token: "ghp_import_token_abc" },
2697
2760
  enable_sync: true,
2698
2761
  }),
@@ -2724,6 +2787,7 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
2724
2787
  body: JSON.stringify({
2725
2788
  remote_url: "https://github.com/aaron/my-vault.git",
2726
2789
  mode: "merge",
2790
+ wait: true,
2727
2791
  credentials: { kind: "none" },
2728
2792
  enable_sync: "yes",
2729
2793
  }),
@@ -2769,6 +2833,161 @@ function seedSiblingVault(name: string, originUrl: string): void {
2769
2833
  );
2770
2834
  }
2771
2835
 
2836
+ // ---------------------------------------------------------------------------
2837
+ // vault#640 — the ASYNC import transport.
2838
+ //
2839
+ // The semantic tests above (what gets imported, how sync gets wired, which
2840
+ // credential is picked) run through `wait: true`, the back-compat synchronous
2841
+ // path. That's deliberate, not laziness: both paths call the SAME `runImport`
2842
+ // closure, so transport and semantics are genuinely orthogonal and testing
2843
+ // each import behaviour twice would buy nothing. This block covers the part
2844
+ // that IS different — the job lifecycle the SPA actually drives.
2845
+ // ---------------------------------------------------------------------------
2846
+ describe("handleMirrorImport — async job transport (vault#640)", () => {
2847
+ let home: string;
2848
+ let fixture: string;
2849
+
2850
+ afterEach(() => {
2851
+ if (home) fs.rmSync(home, { recursive: true, force: true });
2852
+ if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
2853
+ _resetImportInFlightForTest();
2854
+ _resetImportJobsForTest();
2855
+ clearVaultStoreCache();
2856
+ });
2857
+
2858
+ /** POST an import, asserting the 202, and hand back the job record. */
2859
+ async function startImport(
2860
+ spawn: GitSpawn,
2861
+ body: Record<string, unknown> = {},
2862
+ ): Promise<ImportJob> {
2863
+ const req = new Request("http://x/import", {
2864
+ method: "POST",
2865
+ body: JSON.stringify({
2866
+ remote_url: "https://github.com/a/b.git",
2867
+ mode: "merge",
2868
+ credentials: { kind: "none" },
2869
+ ...body,
2870
+ }),
2871
+ });
2872
+ const res = await handleMirrorImport(req, "default", spawn);
2873
+ expect(res.status).toBe(202);
2874
+ return (await res.json()) as ImportJob;
2875
+ }
2876
+
2877
+ /** Poll the status route until the job leaves `running`. */
2878
+ async function pollUntilDone(jobId: string, vault = "default"): Promise<ImportJob> {
2879
+ for (let i = 0; i < 200; i++) {
2880
+ const res = handleMirrorImportStatus(vault, jobId);
2881
+ const job = (await res.json()) as ImportJob;
2882
+ if (job.status !== "running") return job;
2883
+ await new Promise((r) => setTimeout(r, 10));
2884
+ }
2885
+ throw new Error("import job never reached a terminal state");
2886
+ }
2887
+
2888
+ test("POST returns 202 with a running job, not the result", async () => {
2889
+ home = tmp("import-async-202-");
2890
+ await bootstrapVault(home);
2891
+ fixture = await buildExportFixture();
2892
+
2893
+ const job = await startImport(spawnCloneSuccess(fixture));
2894
+ expect(job.job_id).toBeTruthy();
2895
+ expect(job.status).toBe("running");
2896
+ expect(job.stage).toBe("cloning");
2897
+ expect(job.vault_name).toBe("default");
2898
+
2899
+ await pollUntilDone(job.job_id);
2900
+ });
2901
+
2902
+ test("polling reaches succeeded and carries the import result", async () => {
2903
+ home = tmp("import-async-ok-");
2904
+ await bootstrapVault(home);
2905
+ fixture = await buildExportFixture();
2906
+
2907
+ const started = await startImport(spawnCloneSuccess(fixture));
2908
+ const done = await pollUntilDone(started.job_id);
2909
+
2910
+ expect(done.status).toBe("succeeded");
2911
+ expect(done.finished_at).toBeTruthy();
2912
+ expect(done.result?.notes_imported).toBe(2);
2913
+ expect(done.error).toBeUndefined();
2914
+ });
2915
+
2916
+ test("a clone failure lands on the job as clone_failed, with the token redacted", async () => {
2917
+ home = tmp("import-async-fail-");
2918
+ await bootstrapVault(home);
2919
+
2920
+ const started = await startImport(spawnCloneFail, {
2921
+ credentials: { kind: "pat", token: "ghp_secret_xyz" },
2922
+ });
2923
+ const done = await pollUntilDone(started.job_id);
2924
+
2925
+ expect(done.status).toBe("failed");
2926
+ expect(done.error?.error_type).toBe("clone_failed");
2927
+ expect(JSON.stringify(done)).not.toContain("ghp_secret_xyz");
2928
+ });
2929
+
2930
+ test("a second import while one is running → 409 carrying the in-flight job_id", async () => {
2931
+ home = tmp("import-async-conflict-");
2932
+ await bootstrapVault(home);
2933
+ fixture = await buildExportFixture();
2934
+
2935
+ const first = await startImport(spawnCloneSuccess(fixture));
2936
+ const req = new Request("http://x/import", {
2937
+ method: "POST",
2938
+ body: JSON.stringify({
2939
+ remote_url: "https://github.com/a/b.git",
2940
+ mode: "merge",
2941
+ credentials: { kind: "none" },
2942
+ }),
2943
+ });
2944
+ const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
2945
+ expect(res.status).toBe(409);
2946
+ const body = (await res.json()) as { error_type: string; job_id?: string };
2947
+ expect(body.error_type).toBe("concurrent_import");
2948
+ // The second tab can attach to the running import instead of being stuck.
2949
+ expect(body.job_id).toBe(first.job_id);
2950
+
2951
+ await pollUntilDone(first.job_id);
2952
+ });
2953
+
2954
+ test("a finished job frees the vault for the next import", async () => {
2955
+ home = tmp("import-async-serial-");
2956
+ await bootstrapVault(home);
2957
+ fixture = await buildExportFixture();
2958
+
2959
+ const first = await startImport(spawnCloneSuccess(fixture));
2960
+ await pollUntilDone(first.job_id);
2961
+
2962
+ const second = await startImport(spawnCloneSuccess(fixture));
2963
+ expect(second.job_id).not.toBe(first.job_id);
2964
+ await pollUntilDone(second.job_id);
2965
+ });
2966
+
2967
+ test("status is scoped to the vault — another vault's admin can't read the job", async () => {
2968
+ home = tmp("import-async-scope-");
2969
+ await bootstrapVault(home);
2970
+ fixture = await buildExportFixture();
2971
+
2972
+ const started = await startImport(spawnCloneSuccess(fixture));
2973
+ const res = handleMirrorImportStatus("some-other-vault", started.job_id);
2974
+ expect(res.status).toBe(404);
2975
+ const body = (await res.json()) as { error_type: string };
2976
+ expect(body.error_type).toBe("job_not_found");
2977
+
2978
+ await pollUntilDone(started.job_id);
2979
+ });
2980
+
2981
+ test("unknown job id → 404 job_not_found", async () => {
2982
+ home = tmp("import-async-404-");
2983
+ await bootstrapVault(home);
2984
+ const res = handleMirrorImportStatus("default", "no-such-job");
2985
+ expect(res.status).toBe(404);
2986
+ const body = (await res.json()) as { error_type: string };
2987
+ expect(body.error_type).toBe("job_not_found");
2988
+ });
2989
+ });
2990
+
2772
2991
  describe("cross-vault remote-clobber guard (vault#482)", () => {
2773
2992
  let home: string;
2774
2993
  afterEach(() => {