@openparachute/vault 0.4.8 → 0.4.9-rc.10
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/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -16,7 +16,39 @@ import {
|
|
|
16
16
|
MirrorManager,
|
|
17
17
|
type MirrorDeps,
|
|
18
18
|
} from "./mirror-manager.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
_resetDeviceFlowSessionsForTest,
|
|
21
|
+
handleAuthDelete,
|
|
22
|
+
handleAuthGet,
|
|
23
|
+
handleAuthGithubCreateRepo,
|
|
24
|
+
handleAuthGithubDeviceCode,
|
|
25
|
+
handleAuthGithubPoll,
|
|
26
|
+
handleAuthGithubRepos,
|
|
27
|
+
handleAuthGithubSelectRepo,
|
|
28
|
+
handleAuthPat,
|
|
29
|
+
handleMirrorGet,
|
|
30
|
+
handleMirrorImport,
|
|
31
|
+
handleMirrorPushNow,
|
|
32
|
+
handleMirrorPut,
|
|
33
|
+
handleMirrorRunNow,
|
|
34
|
+
} from "./mirror-routes.ts";
|
|
35
|
+
import {
|
|
36
|
+
_resetImportInFlightForTest,
|
|
37
|
+
type GitSpawn,
|
|
38
|
+
} from "./mirror-import.ts";
|
|
39
|
+
import { writeVaultConfig } from "./config.ts";
|
|
40
|
+
import { clearVaultStoreCache } from "./vault-store.ts";
|
|
41
|
+
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
42
|
+
import { Database } from "bun:sqlite";
|
|
43
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
44
|
+
import { cpSync, writeFileSync as nodeWriteFileSync } from "node:fs";
|
|
45
|
+
import {
|
|
46
|
+
mirrorCredentialsPath,
|
|
47
|
+
readCredentials,
|
|
48
|
+
writeCredentials,
|
|
49
|
+
type MirrorCredentials,
|
|
50
|
+
} from "./mirror-credentials.ts";
|
|
51
|
+
import type { FetchLike } from "./github-device-flow.ts";
|
|
20
52
|
|
|
21
53
|
// Same env-restore pattern as mirror-manager.test.ts — keeps HOME +
|
|
22
54
|
// PARACHUTE_HOME from leaking between test files.
|
|
@@ -294,18 +326,18 @@ describe("handleMirrorPut", () => {
|
|
|
294
326
|
await manager.stop();
|
|
295
327
|
});
|
|
296
328
|
|
|
297
|
-
test("PUT restarts
|
|
329
|
+
test("PUT restarts event-driven mirror lifecycle", async () => {
|
|
298
330
|
home = tmp("mirror-put-restart-");
|
|
299
331
|
const { manager } = makeManager(home);
|
|
300
|
-
// Enable with
|
|
332
|
+
// Enable with events sync_mode.
|
|
301
333
|
const req1 = new Request("http://x/admin/mirror", {
|
|
302
334
|
method: "PUT",
|
|
303
335
|
body: JSON.stringify({
|
|
304
336
|
enabled: true,
|
|
305
337
|
location: "internal",
|
|
306
|
-
|
|
338
|
+
sync_mode: "events",
|
|
307
339
|
auto_commit: false,
|
|
308
|
-
|
|
340
|
+
safety_net_seconds: 60,
|
|
309
341
|
}),
|
|
310
342
|
});
|
|
311
343
|
const res1 = await handleMirrorPut(req1, manager);
|
|
@@ -352,16 +384,16 @@ describe("handleMirrorPut", () => {
|
|
|
352
384
|
put({
|
|
353
385
|
enabled: true,
|
|
354
386
|
location: "internal",
|
|
355
|
-
|
|
387
|
+
sync_mode: "events",
|
|
356
388
|
auto_commit: false,
|
|
357
|
-
|
|
389
|
+
safety_net_seconds: 60,
|
|
358
390
|
}),
|
|
359
391
|
put({
|
|
360
392
|
enabled: true,
|
|
361
393
|
location: "internal",
|
|
362
|
-
|
|
394
|
+
sync_mode: "events",
|
|
363
395
|
auto_commit: false,
|
|
364
|
-
|
|
396
|
+
safety_net_seconds: 120,
|
|
365
397
|
}),
|
|
366
398
|
]);
|
|
367
399
|
expect(res1.status).toBe(200);
|
|
@@ -374,7 +406,931 @@ describe("handleMirrorPut", () => {
|
|
|
374
406
|
const status = manager.getStatus();
|
|
375
407
|
expect(status.enabled).toBe(true);
|
|
376
408
|
expect(status.watch_running).toBe(true);
|
|
377
|
-
expect(manager.getConfig().
|
|
409
|
+
expect(manager.getConfig().safety_net_seconds).toBe(120);
|
|
410
|
+
await manager.stop();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// POST /.parachute/mirror/run-now
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
describe("handleMirrorRunNow", () => {
|
|
419
|
+
let home: string;
|
|
420
|
+
afterEach(() => {
|
|
421
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("returns 400 when mirror is disabled (avoids stale-status no-op)", async () => {
|
|
425
|
+
home = tmp("mirror-runnow-disabled-");
|
|
426
|
+
const { manager, exportCalls } = makeManager(home);
|
|
427
|
+
const res = await handleMirrorRunNow(manager);
|
|
428
|
+
expect(res.status).toBe(400);
|
|
429
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
430
|
+
expect(body.error).toContain("not enabled");
|
|
431
|
+
// The disabled-guard short-circuits BEFORE manager.runNow(), so no
|
|
432
|
+
// export attempt happens — pinning this distinguishes the guard from
|
|
433
|
+
// a "200 with stale status" pass-through that would have looked
|
|
434
|
+
// identical to the operator.
|
|
435
|
+
expect(exportCalls()).toHaveLength(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("fires an export pass and returns the updated config+status on success", async () => {
|
|
439
|
+
home = tmp("mirror-runnow-happy-");
|
|
440
|
+
const { manager, deps, exportCalls } = makeManager(home);
|
|
441
|
+
deps.writeMirrorConfig({
|
|
442
|
+
...defaultMirrorConfig(),
|
|
443
|
+
enabled: true,
|
|
444
|
+
location: "internal",
|
|
445
|
+
watch: false,
|
|
446
|
+
auto_commit: false,
|
|
447
|
+
});
|
|
448
|
+
await manager.start();
|
|
449
|
+
// The initial export from start() already ran once. We pin the
|
|
450
|
+
// delta — run-now must trigger a SECOND export pass and the
|
|
451
|
+
// response must carry the updated status.
|
|
452
|
+
const exportsBefore = exportCalls().length;
|
|
453
|
+
const res = await handleMirrorRunNow(manager);
|
|
454
|
+
expect(res.status).toBe(200);
|
|
455
|
+
const body = (await res.json()) as {
|
|
456
|
+
config: MirrorConfig;
|
|
457
|
+
status: { enabled: boolean; last_export_at: string | null; mirror_path: string };
|
|
458
|
+
};
|
|
459
|
+
expect(body.config.enabled).toBe(true);
|
|
460
|
+
expect(body.status.enabled).toBe(true);
|
|
461
|
+
expect(body.status.last_export_at).not.toBeNull();
|
|
462
|
+
expect(body.status.mirror_path).toContain("mirror");
|
|
463
|
+
expect(exportCalls().length).toBe(exportsBefore + 1);
|
|
464
|
+
await manager.stop();
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// /.parachute/mirror/auth/* — credential routes (Cut 3)
|
|
470
|
+
//
|
|
471
|
+
// These routes back the SPA's "Connect GitHub" / "Use PAT" / "Disconnect"
|
|
472
|
+
// flows. The route layer is tested in isolation: we inject a mock fetch
|
|
473
|
+
// so GitHub's API calls don't go over the wire, point PARACHUTE_HOME at
|
|
474
|
+
// a tempdir so credentials writes don't touch real operator state, and
|
|
475
|
+
// drive the routes via the same Request/Response API the live router
|
|
476
|
+
// uses.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
function buildMockFetch(
|
|
480
|
+
responses: Array<{ match: (u: string) => boolean; body: unknown; status?: number }>,
|
|
481
|
+
): FetchLike {
|
|
482
|
+
let idx = 0;
|
|
483
|
+
return async (url) => {
|
|
484
|
+
for (let i = idx; i < responses.length; i++) {
|
|
485
|
+
if (responses[i]!.match(url)) {
|
|
486
|
+
idx = i + 1;
|
|
487
|
+
const r = responses[i]!;
|
|
488
|
+
return {
|
|
489
|
+
ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
|
|
490
|
+
status: r.status ?? 200,
|
|
491
|
+
text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body)),
|
|
492
|
+
json: async () => r.body,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw new Error(`mockFetch: no matching response for ${url}`);
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
describe("auth credential routes — GET /auth", () => {
|
|
501
|
+
let home: string;
|
|
502
|
+
afterEach(() => {
|
|
503
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
504
|
+
_resetDeviceFlowSessionsForTest();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("returns fully-null sanitized shape when no credentials stored", async () => {
|
|
508
|
+
home = tmp("mirror-auth-empty-");
|
|
509
|
+
makeManager(home);
|
|
510
|
+
const res = handleAuthGet();
|
|
511
|
+
expect(res.status).toBe(200);
|
|
512
|
+
const body = (await res.json()) as { active_method: null; github_oauth: null; pat: null };
|
|
513
|
+
expect(body.active_method).toBeNull();
|
|
514
|
+
expect(body.github_oauth).toBeNull();
|
|
515
|
+
expect(body.pat).toBeNull();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("returns sanitized shape when github_oauth credentials present (no token leaks)", async () => {
|
|
519
|
+
home = tmp("mirror-auth-oauth-");
|
|
520
|
+
makeManager(home);
|
|
521
|
+
const creds: MirrorCredentials = {
|
|
522
|
+
active_method: "github_oauth",
|
|
523
|
+
github_oauth: {
|
|
524
|
+
access_token: "gho_secret123456789",
|
|
525
|
+
scope: "repo",
|
|
526
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
527
|
+
user_login: "aaron",
|
|
528
|
+
user_id: 1,
|
|
529
|
+
},
|
|
530
|
+
pat: null,
|
|
531
|
+
};
|
|
532
|
+
writeCredentials(creds);
|
|
533
|
+
const res = handleAuthGet();
|
|
534
|
+
expect(res.status).toBe(200);
|
|
535
|
+
const text = await res.text();
|
|
536
|
+
expect(text).not.toContain("gho_secret");
|
|
537
|
+
const body = JSON.parse(text) as { github_oauth: { user_login: string; token_preview: string } };
|
|
538
|
+
expect(body.github_oauth.user_login).toBe("aaron");
|
|
539
|
+
expect(body.github_oauth.token_preview).toBe("gho_…6789");
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("auth credential routes — DELETE /auth", () => {
|
|
544
|
+
let home: string;
|
|
545
|
+
afterEach(() => {
|
|
546
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
547
|
+
_resetDeviceFlowSessionsForTest();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("wipes credentials from disk", async () => {
|
|
551
|
+
home = tmp("mirror-auth-delete-");
|
|
552
|
+
const { manager } = makeManager(home);
|
|
553
|
+
writeCredentials({
|
|
554
|
+
active_method: "pat",
|
|
555
|
+
github_oauth: null,
|
|
556
|
+
pat: {
|
|
557
|
+
token: "ghp_xxxxxxxxxxxxxxxxxxxx",
|
|
558
|
+
remote_url: "https://github.com/a/b.git",
|
|
559
|
+
label: "test",
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
expect(fs.existsSync(mirrorCredentialsPath())).toBe(true);
|
|
563
|
+
const res = await handleAuthDelete(manager);
|
|
564
|
+
expect(res.status).toBe(200);
|
|
565
|
+
expect(fs.existsSync(mirrorCredentialsPath())).toBe(false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("idempotent — missing credentials file still 200", async () => {
|
|
569
|
+
home = tmp("mirror-auth-delete-empty-");
|
|
570
|
+
const { manager } = makeManager(home);
|
|
571
|
+
const res = await handleAuthDelete(manager);
|
|
572
|
+
expect(res.status).toBe(200);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("auth credential routes — device flow", () => {
|
|
577
|
+
let home: string;
|
|
578
|
+
afterEach(() => {
|
|
579
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
580
|
+
_resetDeviceFlowSessionsForTest();
|
|
581
|
+
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("device-code returns 503 with placeholder client_id (no env override)", async () => {
|
|
585
|
+
home = tmp("mirror-auth-placeholder-");
|
|
586
|
+
makeManager(home);
|
|
587
|
+
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
588
|
+
const res = await handleAuthGithubDeviceCode();
|
|
589
|
+
expect(res.status).toBe(503);
|
|
590
|
+
const body = (await res.json()) as { error_type: string };
|
|
591
|
+
expect(body.error_type).toBe("placeholder_client_id");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("poll without polling_id returns 400", async () => {
|
|
595
|
+
home = tmp("mirror-auth-poll-bad-");
|
|
596
|
+
const { manager } = makeManager(home);
|
|
597
|
+
process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
|
|
598
|
+
const req = new Request("http://x/auth/github/poll", {
|
|
599
|
+
method: "POST",
|
|
600
|
+
body: JSON.stringify({}),
|
|
601
|
+
});
|
|
602
|
+
const res = await handleAuthGithubPoll(req, manager);
|
|
603
|
+
expect(res.status).toBe(400);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("poll with unknown polling_id returns 404 with expired state", async () => {
|
|
607
|
+
home = tmp("mirror-auth-poll-unknown-");
|
|
608
|
+
const { manager } = makeManager(home);
|
|
609
|
+
process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
|
|
610
|
+
const req = new Request("http://x/auth/github/poll", {
|
|
611
|
+
method: "POST",
|
|
612
|
+
body: JSON.stringify({ polling_id: "nonexistent" }),
|
|
613
|
+
});
|
|
614
|
+
const res = await handleAuthGithubPoll(req, manager);
|
|
615
|
+
expect(res.status).toBe(404);
|
|
616
|
+
const body = (await res.json()) as { state: string };
|
|
617
|
+
expect(body.state).toBe("expired");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("full device flow with mocked fetch — code → granted → user → credentials saved", async () => {
|
|
621
|
+
home = tmp("mirror-auth-flow-");
|
|
622
|
+
const { manager } = makeManager(home);
|
|
623
|
+
process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
|
|
624
|
+
|
|
625
|
+
// device-code request
|
|
626
|
+
const fetchA = buildMockFetch([
|
|
627
|
+
{
|
|
628
|
+
match: (u) => u.includes("/login/device/code"),
|
|
629
|
+
body: {
|
|
630
|
+
device_code: "dev_xyz",
|
|
631
|
+
user_code: "ABCD-1234",
|
|
632
|
+
verification_uri: "https://github.com/login/device",
|
|
633
|
+
expires_in: 900,
|
|
634
|
+
interval: 5,
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
]);
|
|
638
|
+
const codeRes = await handleAuthGithubDeviceCode(fetchA);
|
|
639
|
+
expect(codeRes.status).toBe(200);
|
|
640
|
+
const codeBody = (await codeRes.json()) as { polling_id: string; user_code: string };
|
|
641
|
+
expect(codeBody.user_code).toBe("ABCD-1234");
|
|
642
|
+
// device_code MUST NOT leak in the response.
|
|
643
|
+
expect(JSON.stringify(codeBody)).not.toContain("dev_xyz");
|
|
644
|
+
const polling_id = codeBody.polling_id;
|
|
645
|
+
expect(polling_id.length).toBeGreaterThan(0);
|
|
646
|
+
|
|
647
|
+
// poll once — pending
|
|
648
|
+
const fetchPending = buildMockFetch([
|
|
649
|
+
{
|
|
650
|
+
match: () => true,
|
|
651
|
+
body: { error: "authorization_pending" },
|
|
652
|
+
},
|
|
653
|
+
]);
|
|
654
|
+
const pendingRes = await handleAuthGithubPoll(
|
|
655
|
+
new Request("http://x/poll", { method: "POST", body: JSON.stringify({ polling_id }) }),
|
|
656
|
+
manager,
|
|
657
|
+
fetchPending,
|
|
658
|
+
);
|
|
659
|
+
expect(pendingRes.status).toBe(200);
|
|
660
|
+
const pendingBody = (await pendingRes.json()) as { state: string };
|
|
661
|
+
expect(pendingBody.state).toBe("pending");
|
|
662
|
+
|
|
663
|
+
// poll once — granted, fetch user, save credentials
|
|
664
|
+
const fetchGranted = buildMockFetch([
|
|
665
|
+
{
|
|
666
|
+
match: (u) => u.includes("/login/oauth/access_token"),
|
|
667
|
+
body: { access_token: "gho_real1234567890", scope: "repo", token_type: "bearer" },
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
match: (u) => u.includes("/user"),
|
|
671
|
+
body: { login: "aaron", id: 12345, name: "Aaron G", avatar_url: "https://x/y.png" },
|
|
672
|
+
},
|
|
673
|
+
]);
|
|
674
|
+
const grantRes = await handleAuthGithubPoll(
|
|
675
|
+
new Request("http://x/poll", { method: "POST", body: JSON.stringify({ polling_id }) }),
|
|
676
|
+
manager,
|
|
677
|
+
fetchGranted,
|
|
678
|
+
);
|
|
679
|
+
expect(grantRes.status).toBe(200);
|
|
680
|
+
const grantBody = (await grantRes.json()) as {
|
|
681
|
+
state: string;
|
|
682
|
+
user: { login: string; id: number };
|
|
683
|
+
};
|
|
684
|
+
expect(grantBody.state).toBe("granted");
|
|
685
|
+
expect(grantBody.user.login).toBe("aaron");
|
|
686
|
+
expect(grantBody.user.id).toBe(12345);
|
|
687
|
+
// Credentials persisted; no token leak in response.
|
|
688
|
+
expect(JSON.stringify(grantBody)).not.toContain("gho_real");
|
|
689
|
+
const saved = readCredentials();
|
|
690
|
+
expect(saved?.active_method).toBe("github_oauth");
|
|
691
|
+
expect(saved?.github_oauth?.access_token).toBe("gho_real1234567890");
|
|
692
|
+
expect(saved?.github_oauth?.user_login).toBe("aaron");
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
describe("auth credential routes — PAT", () => {
|
|
697
|
+
let home: string;
|
|
698
|
+
afterEach(() => {
|
|
699
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("rejects missing token with 400", async () => {
|
|
703
|
+
home = tmp("mirror-auth-pat-notoken-");
|
|
704
|
+
const { manager } = makeManager(home);
|
|
705
|
+
const res = await handleAuthPat(
|
|
706
|
+
new Request("http://x/pat", {
|
|
707
|
+
method: "POST",
|
|
708
|
+
body: JSON.stringify({ remote_url: "https://github.com/a/b.git" }),
|
|
709
|
+
}),
|
|
710
|
+
manager,
|
|
711
|
+
);
|
|
712
|
+
expect(res.status).toBe(400);
|
|
713
|
+
const body = (await res.json()) as { field: string };
|
|
714
|
+
expect(body.field).toBe("token");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("rejects missing remote_url with 400", async () => {
|
|
718
|
+
home = tmp("mirror-auth-pat-nourl-");
|
|
719
|
+
const { manager } = makeManager(home);
|
|
720
|
+
const res = await handleAuthPat(
|
|
721
|
+
new Request("http://x/pat", {
|
|
722
|
+
method: "POST",
|
|
723
|
+
body: JSON.stringify({ token: "ghp_x" }),
|
|
724
|
+
}),
|
|
725
|
+
manager,
|
|
726
|
+
);
|
|
727
|
+
expect(res.status).toBe(400);
|
|
728
|
+
const body = (await res.json()) as { field: string };
|
|
729
|
+
expect(body.field).toBe("remote_url");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("rejects non-HTTPS remote_url (SSH/file URLs not yet supported)", async () => {
|
|
733
|
+
home = tmp("mirror-auth-pat-ssh-");
|
|
734
|
+
const { manager } = makeManager(home);
|
|
735
|
+
const res = await handleAuthPat(
|
|
736
|
+
new Request("http://x/pat", {
|
|
737
|
+
method: "POST",
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
token: "ghp_x",
|
|
740
|
+
remote_url: "git@github.com:owner/repo.git",
|
|
741
|
+
}),
|
|
742
|
+
}),
|
|
743
|
+
manager,
|
|
744
|
+
);
|
|
745
|
+
expect(res.status).toBe(400);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("probe-fail returns 400 with redacted error message (no token leak)", async () => {
|
|
749
|
+
// Probe against a definitely-not-real domain. We bypass the network
|
|
750
|
+
// path via a token that should cause `git ls-remote` to fail
|
|
751
|
+
// immediately. The interesting assertion is "the error message we
|
|
752
|
+
// surface back doesn't include the token literal".
|
|
753
|
+
home = tmp("mirror-auth-pat-probefail-");
|
|
754
|
+
const { manager } = makeManager(home);
|
|
755
|
+
const secret = "ghp_definitelynotvalidatall1234567890";
|
|
756
|
+
const res = await handleAuthPat(
|
|
757
|
+
new Request("http://x/pat", {
|
|
758
|
+
method: "POST",
|
|
759
|
+
body: JSON.stringify({
|
|
760
|
+
token: secret,
|
|
761
|
+
remote_url: "https://nonexistent.parachute.test/owner/repo.git",
|
|
762
|
+
}),
|
|
763
|
+
}),
|
|
764
|
+
manager,
|
|
765
|
+
);
|
|
766
|
+
expect(res.status).toBe(400);
|
|
767
|
+
const text = await res.text();
|
|
768
|
+
expect(text).not.toContain(secret);
|
|
769
|
+
}, 20_000);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// Cuts 3 + 6: credential save → auto_push flipped on + initial push fires.
|
|
774
|
+
//
|
|
775
|
+
// The PAT route gates on a real `git ls-remote` probe, so it's awkward
|
|
776
|
+
// to exercise end-to-end in a hermetic test. The select-repo route has
|
|
777
|
+
// no probe — it accepts the operator's already-OAuth'd token and wires
|
|
778
|
+
// the URL. We drive the side-effect helpers (maybeEnableAutoPush +
|
|
779
|
+
// maybeFireInitialPush) through select-repo, with a local bare repo as
|
|
780
|
+
// the "GitHub" remote — overridden via post-save remote-URL rewrite so
|
|
781
|
+
// pushNow targets the test repo.
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)", () => {
|
|
785
|
+
let home: string;
|
|
786
|
+
let remote: string;
|
|
787
|
+
afterEach(() => {
|
|
788
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
789
|
+
if (remote) fs.rmSync(remote, { recursive: true, force: true });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("select-repo flips auto_push from false → true on an enabled internal mirror", async () => {
|
|
793
|
+
home = tmp("mirror-selectrepo-autopush-");
|
|
794
|
+
remote = tmp("mirror-selectrepo-remote-");
|
|
795
|
+
Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
|
|
796
|
+
|
|
797
|
+
const { manager, deps } = makeManager(home);
|
|
798
|
+
deps.writeMirrorConfig({
|
|
799
|
+
...defaultMirrorConfig(),
|
|
800
|
+
enabled: true,
|
|
801
|
+
location: "internal",
|
|
802
|
+
sync_mode: "manual",
|
|
803
|
+
auto_commit: false,
|
|
804
|
+
auto_push: false,
|
|
805
|
+
});
|
|
806
|
+
await manager.start();
|
|
807
|
+
expect(manager.getConfig().auto_push).toBe(false);
|
|
808
|
+
|
|
809
|
+
// Seed github_oauth credentials. select-repo doesn't probe; it just
|
|
810
|
+
// sets origin to github.com/<owner>/<repo>.git. We then rewrite
|
|
811
|
+
// origin to our local bare repo so pushNow has somewhere to land.
|
|
812
|
+
writeCredentials({
|
|
813
|
+
active_method: "github_oauth",
|
|
814
|
+
github_oauth: {
|
|
815
|
+
access_token: "gho_fake1234567890abcd",
|
|
816
|
+
scope: "repo",
|
|
817
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
818
|
+
user_login: "aaron",
|
|
819
|
+
user_id: 1,
|
|
820
|
+
},
|
|
821
|
+
pat: null,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const res = await handleAuthGithubSelectRepo(
|
|
825
|
+
new Request("http://x/select", {
|
|
826
|
+
method: "POST",
|
|
827
|
+
body: JSON.stringify({ owner: "aaron", name: "my-vault" }),
|
|
828
|
+
}),
|
|
829
|
+
manager,
|
|
830
|
+
);
|
|
831
|
+
expect(res.status).toBe(200);
|
|
832
|
+
const body = (await res.json()) as {
|
|
833
|
+
ok: boolean;
|
|
834
|
+
auto_push_was_already_enabled: boolean;
|
|
835
|
+
auto_push_enabled: boolean;
|
|
836
|
+
initial_push:
|
|
837
|
+
| { fired: false; reason: string }
|
|
838
|
+
| { fired: true; pushed: boolean; error?: string; sha?: string };
|
|
839
|
+
};
|
|
840
|
+
// Cut 3 — auto_push went from false → true.
|
|
841
|
+
expect(body.auto_push_was_already_enabled).toBe(false);
|
|
842
|
+
expect(body.auto_push_enabled).toBe(true);
|
|
843
|
+
expect(manager.getConfig().auto_push).toBe(true);
|
|
844
|
+
// Cut 6 — initial push fired. It points at github.com which doesn't
|
|
845
|
+
// exist for our fake creds → expected to fail with a redacted error
|
|
846
|
+
// in last_push_error, but the helper still reports fired=true.
|
|
847
|
+
expect(body.initial_push.fired).toBe(true);
|
|
848
|
+
if (body.initial_push.fired === true) {
|
|
849
|
+
// Push failure is fine — point is "we tried."
|
|
850
|
+
expect(typeof body.initial_push.pushed).toBe("boolean");
|
|
851
|
+
}
|
|
852
|
+
// Token doesn't leak into the response.
|
|
853
|
+
expect(JSON.stringify(body)).not.toContain("gho_fake1234567890");
|
|
854
|
+
await manager.stop();
|
|
855
|
+
}, 30_000);
|
|
856
|
+
|
|
857
|
+
test("select-repo is a no-op for auto_push when mirror is disabled", async () => {
|
|
858
|
+
// Operator wiring credentials before flipping the mirror on — don't
|
|
859
|
+
// mutate auto_push behind their back.
|
|
860
|
+
home = tmp("mirror-selectrepo-disabled-");
|
|
861
|
+
const { manager, deps } = makeManager(home);
|
|
862
|
+
deps.writeMirrorConfig({
|
|
863
|
+
...defaultMirrorConfig(),
|
|
864
|
+
enabled: false,
|
|
865
|
+
auto_push: false,
|
|
866
|
+
});
|
|
867
|
+
await manager.start();
|
|
868
|
+
writeCredentials({
|
|
869
|
+
active_method: "github_oauth",
|
|
870
|
+
github_oauth: {
|
|
871
|
+
access_token: "gho_anothertoken12345",
|
|
872
|
+
scope: "repo",
|
|
873
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
874
|
+
user_login: "aaron",
|
|
875
|
+
user_id: 1,
|
|
876
|
+
},
|
|
877
|
+
pat: null,
|
|
878
|
+
});
|
|
879
|
+
const res = await handleAuthGithubSelectRepo(
|
|
880
|
+
new Request("http://x/select", {
|
|
881
|
+
method: "POST",
|
|
882
|
+
body: JSON.stringify({ owner: "aaron", name: "v" }),
|
|
883
|
+
}),
|
|
884
|
+
manager,
|
|
885
|
+
);
|
|
886
|
+
expect(res.status).toBe(200);
|
|
887
|
+
expect(manager.getConfig().auto_push).toBe(false);
|
|
378
888
|
await manager.stop();
|
|
379
889
|
});
|
|
380
890
|
});
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Cut 6: POST /.parachute/mirror/push-now route handler.
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
describe("handleMirrorPushNow — Cut 6", () => {
|
|
897
|
+
let home: string;
|
|
898
|
+
afterEach(() => {
|
|
899
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("returns 400 when mirror is not enabled", async () => {
|
|
903
|
+
home = tmp("mirror-pushnow-disabled-");
|
|
904
|
+
const { manager, deps } = makeManager(home);
|
|
905
|
+
deps.writeMirrorConfig({ ...defaultMirrorConfig(), enabled: false });
|
|
906
|
+
await manager.start();
|
|
907
|
+
const res = await handleMirrorPushNow(manager);
|
|
908
|
+
expect(res.status).toBe(400);
|
|
909
|
+
const body = (await res.json()) as { error: string };
|
|
910
|
+
expect(body.error).toBe("Mirror not enabled");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("returns 200 + push outcome when mirror is enabled (even on push failure)", async () => {
|
|
914
|
+
home = tmp("mirror-pushnow-enabled-");
|
|
915
|
+
const { manager, deps } = makeManager(home);
|
|
916
|
+
deps.writeMirrorConfig({
|
|
917
|
+
...defaultMirrorConfig(),
|
|
918
|
+
enabled: true,
|
|
919
|
+
location: "internal",
|
|
920
|
+
sync_mode: "manual",
|
|
921
|
+
auto_commit: false,
|
|
922
|
+
});
|
|
923
|
+
await manager.start();
|
|
924
|
+
// No remote → push will fail; the handler still returns 200 with
|
|
925
|
+
// the failure surface in the body. Push failures aren't 500s.
|
|
926
|
+
const res = await handleMirrorPushNow(manager);
|
|
927
|
+
expect(res.status).toBe(200);
|
|
928
|
+
const body = (await res.json()) as {
|
|
929
|
+
status: { last_push_error: string | null };
|
|
930
|
+
push: { fired: boolean };
|
|
931
|
+
};
|
|
932
|
+
expect(body.push.fired).toBe(true);
|
|
933
|
+
expect(body.status.last_push_error).not.toBeNull();
|
|
934
|
+
await manager.stop();
|
|
935
|
+
}, 30_000);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
describe("auth credential routes — github repos / create-repo", () => {
|
|
939
|
+
let home: string;
|
|
940
|
+
afterEach(() => {
|
|
941
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("repos returns 400 when not connected to GitHub", async () => {
|
|
945
|
+
home = tmp("mirror-auth-repos-noauth-");
|
|
946
|
+
makeManager(home);
|
|
947
|
+
const res = await handleAuthGithubRepos();
|
|
948
|
+
expect(res.status).toBe(400);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("repos returns list when authed", async () => {
|
|
952
|
+
home = tmp("mirror-auth-repos-ok-");
|
|
953
|
+
makeManager(home);
|
|
954
|
+
writeCredentials({
|
|
955
|
+
active_method: "github_oauth",
|
|
956
|
+
github_oauth: {
|
|
957
|
+
access_token: "gho_test1234567890",
|
|
958
|
+
scope: "repo",
|
|
959
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
960
|
+
user_login: "aaron",
|
|
961
|
+
user_id: 1,
|
|
962
|
+
},
|
|
963
|
+
pat: null,
|
|
964
|
+
});
|
|
965
|
+
const fetcher = buildMockFetch([
|
|
966
|
+
{
|
|
967
|
+
match: (u) => u.includes("/user/repos"),
|
|
968
|
+
body: [
|
|
969
|
+
{
|
|
970
|
+
name: "a",
|
|
971
|
+
full_name: "aaron/a",
|
|
972
|
+
private: true,
|
|
973
|
+
html_url: "https://github.com/aaron/a",
|
|
974
|
+
description: null,
|
|
975
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
976
|
+
clone_url: "https://github.com/aaron/a.git",
|
|
977
|
+
owner: { login: "aaron" },
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
},
|
|
981
|
+
]);
|
|
982
|
+
const res = await handleAuthGithubRepos(fetcher);
|
|
983
|
+
expect(res.status).toBe(200);
|
|
984
|
+
const body = (await res.json()) as { repos: Array<{ full_name: string }> };
|
|
985
|
+
expect(body.repos).toHaveLength(1);
|
|
986
|
+
expect(body.repos[0]!.full_name).toBe("aaron/a");
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("create-repo proxies through with mocked fetch", async () => {
|
|
990
|
+
home = tmp("mirror-auth-create-repo-");
|
|
991
|
+
makeManager(home);
|
|
992
|
+
writeCredentials({
|
|
993
|
+
active_method: "github_oauth",
|
|
994
|
+
github_oauth: {
|
|
995
|
+
access_token: "gho_test1234567890",
|
|
996
|
+
scope: "repo",
|
|
997
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
998
|
+
user_login: "aaron",
|
|
999
|
+
user_id: 1,
|
|
1000
|
+
},
|
|
1001
|
+
pat: null,
|
|
1002
|
+
});
|
|
1003
|
+
const fetcher = buildMockFetch([
|
|
1004
|
+
{
|
|
1005
|
+
match: (u) => u.includes("/user/repos"),
|
|
1006
|
+
status: 201,
|
|
1007
|
+
body: {
|
|
1008
|
+
name: "new-vault",
|
|
1009
|
+
full_name: "aaron/new-vault",
|
|
1010
|
+
private: true,
|
|
1011
|
+
html_url: "https://github.com/aaron/new-vault",
|
|
1012
|
+
description: "x",
|
|
1013
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
1014
|
+
clone_url: "https://github.com/aaron/new-vault.git",
|
|
1015
|
+
owner: { login: "aaron" },
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
]);
|
|
1019
|
+
const req = new Request("http://x/create", {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
body: JSON.stringify({ name: "new-vault" }),
|
|
1022
|
+
});
|
|
1023
|
+
const res = await handleAuthGithubCreateRepo(req, fetcher);
|
|
1024
|
+
expect(res.status).toBe(200);
|
|
1025
|
+
const body = (await res.json()) as { full_name: string };
|
|
1026
|
+
expect(body.full_name).toBe("aaron/new-vault");
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// ---------------------------------------------------------------------------
|
|
1031
|
+
// POST /.parachute/mirror/import — clone-and-import HTTP route (vault#391).
|
|
1032
|
+
// ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
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", () => {
|
|
1085
|
+
let home: string;
|
|
1086
|
+
let fixture: string;
|
|
1087
|
+
|
|
1088
|
+
afterEach(() => {
|
|
1089
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
1090
|
+
if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
|
|
1091
|
+
_resetImportInFlightForTest();
|
|
1092
|
+
clearVaultStoreCache();
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("rejects invalid JSON body with 400", async () => {
|
|
1096
|
+
home = tmp("import-route-badjson-");
|
|
1097
|
+
await bootstrapVault(home);
|
|
1098
|
+
const req = new Request("http://x/import", {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
body: "{not-json",
|
|
1101
|
+
});
|
|
1102
|
+
const res = await handleMirrorImport(req, "default");
|
|
1103
|
+
expect(res.status).toBe(400);
|
|
1104
|
+
const body = (await res.json()) as { error_type: string };
|
|
1105
|
+
expect(body.error_type).toBe("invalid_json");
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("rejects missing remote_url with 400", async () => {
|
|
1109
|
+
home = tmp("import-route-no-url-");
|
|
1110
|
+
await bootstrapVault(home);
|
|
1111
|
+
const req = new Request("http://x/import", {
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
body: JSON.stringify({ mode: "merge" }),
|
|
1114
|
+
});
|
|
1115
|
+
const res = await handleMirrorImport(req, "default");
|
|
1116
|
+
expect(res.status).toBe(400);
|
|
1117
|
+
const body = (await res.json()) as { field: string };
|
|
1118
|
+
expect(body.field).toBe("remote_url");
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("rejects invalid mode with 400", async () => {
|
|
1122
|
+
home = tmp("import-route-bad-mode-");
|
|
1123
|
+
await bootstrapVault(home);
|
|
1124
|
+
const req = new Request("http://x/import", {
|
|
1125
|
+
method: "POST",
|
|
1126
|
+
body: JSON.stringify({ remote_url: "https://github.com/a/b.git", mode: "wipe" }),
|
|
1127
|
+
});
|
|
1128
|
+
const res = await handleMirrorImport(req, "default");
|
|
1129
|
+
expect(res.status).toBe(400);
|
|
1130
|
+
const body = (await res.json()) as { field: string };
|
|
1131
|
+
expect(body.field).toBe("mode");
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test("rejects per-call PAT without token", async () => {
|
|
1135
|
+
home = tmp("import-route-pat-missing-token-");
|
|
1136
|
+
await bootstrapVault(home);
|
|
1137
|
+
const req = new Request("http://x/import", {
|
|
1138
|
+
method: "POST",
|
|
1139
|
+
body: JSON.stringify({
|
|
1140
|
+
remote_url: "https://github.com/a/b.git",
|
|
1141
|
+
mode: "merge",
|
|
1142
|
+
credentials: { kind: "pat", token: "" },
|
|
1143
|
+
}),
|
|
1144
|
+
});
|
|
1145
|
+
const res = await handleMirrorImport(req, "default");
|
|
1146
|
+
expect(res.status).toBe(400);
|
|
1147
|
+
const body = (await res.json()) as { field: string };
|
|
1148
|
+
expect(body.field).toBe("credentials.token");
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test("rejects unknown credentials.kind", async () => {
|
|
1152
|
+
home = tmp("import-route-bad-kind-");
|
|
1153
|
+
await bootstrapVault(home);
|
|
1154
|
+
const req = new Request("http://x/import", {
|
|
1155
|
+
method: "POST",
|
|
1156
|
+
body: JSON.stringify({
|
|
1157
|
+
remote_url: "https://github.com/a/b.git",
|
|
1158
|
+
mode: "merge",
|
|
1159
|
+
credentials: { kind: "magic" },
|
|
1160
|
+
}),
|
|
1161
|
+
});
|
|
1162
|
+
const res = await handleMirrorImport(req, "default");
|
|
1163
|
+
expect(res.status).toBe(400);
|
|
1164
|
+
const body = (await res.json()) as { field: string };
|
|
1165
|
+
expect(body.field).toBe("credentials.kind");
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test("success path — merge mode imports notes into target vault", async () => {
|
|
1169
|
+
home = tmp("import-route-success-");
|
|
1170
|
+
await bootstrapVault(home);
|
|
1171
|
+
fixture = await buildExportFixture();
|
|
1172
|
+
const req = new Request("http://x/import", {
|
|
1173
|
+
method: "POST",
|
|
1174
|
+
body: JSON.stringify({
|
|
1175
|
+
remote_url: "https://github.com/a/b.git",
|
|
1176
|
+
mode: "merge",
|
|
1177
|
+
credentials: { kind: "none" },
|
|
1178
|
+
}),
|
|
1179
|
+
});
|
|
1180
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
|
|
1181
|
+
expect(res.status).toBe(200);
|
|
1182
|
+
const body = (await res.json()) as {
|
|
1183
|
+
notes_imported: number;
|
|
1184
|
+
tags_imported: number;
|
|
1185
|
+
attachments_imported: number;
|
|
1186
|
+
warnings: string[];
|
|
1187
|
+
notes_deleted?: number;
|
|
1188
|
+
};
|
|
1189
|
+
expect(body.notes_imported).toBe(2);
|
|
1190
|
+
expect(body.warnings).toEqual([]);
|
|
1191
|
+
expect(body.notes_deleted).toBeUndefined();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("replace mode sets notes_deleted in response", async () => {
|
|
1195
|
+
home = tmp("import-route-replace-");
|
|
1196
|
+
await bootstrapVault(home);
|
|
1197
|
+
fixture = await buildExportFixture();
|
|
1198
|
+
// Seed the target vault with a local-only note that gets wiped.
|
|
1199
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
1200
|
+
const store = getVaultStore("default");
|
|
1201
|
+
await store.createNote("local", { id: "n-local", path: "local" });
|
|
1202
|
+
|
|
1203
|
+
const req = new Request("http://x/import", {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
body: JSON.stringify({
|
|
1206
|
+
remote_url: "https://github.com/a/b.git",
|
|
1207
|
+
mode: "replace",
|
|
1208
|
+
credentials: { kind: "none" },
|
|
1209
|
+
}),
|
|
1210
|
+
});
|
|
1211
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
|
|
1212
|
+
expect(res.status).toBe(200);
|
|
1213
|
+
const body = (await res.json()) as {
|
|
1214
|
+
notes_imported: number;
|
|
1215
|
+
notes_deleted: number;
|
|
1216
|
+
};
|
|
1217
|
+
expect(body.notes_imported).toBe(2);
|
|
1218
|
+
expect(body.notes_deleted).toBe(1);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("clone failure returns 502 with redacted message", async () => {
|
|
1222
|
+
home = tmp("import-route-clone-fail-");
|
|
1223
|
+
await bootstrapVault(home);
|
|
1224
|
+
const req = new Request("http://x/import", {
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
body: JSON.stringify({
|
|
1227
|
+
remote_url: "https://github.com/a/b.git",
|
|
1228
|
+
mode: "merge",
|
|
1229
|
+
credentials: { kind: "pat", token: "ghp_secret_xyz" },
|
|
1230
|
+
}),
|
|
1231
|
+
});
|
|
1232
|
+
const res = await handleMirrorImport(req, "default", spawnCloneFail);
|
|
1233
|
+
expect(res.status).toBe(502);
|
|
1234
|
+
const text = await res.text();
|
|
1235
|
+
expect(text).not.toContain("ghp_secret_xyz");
|
|
1236
|
+
const body = JSON.parse(text) as { error_type: string };
|
|
1237
|
+
expect(body.error_type).toBe("clone_failed");
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test("not-a-vault-export returns 400 with actionable message", async () => {
|
|
1241
|
+
home = tmp("import-route-not-vault-");
|
|
1242
|
+
await bootstrapVault(home);
|
|
1243
|
+
const notAnExport = tmp("import-route-notvault-fixture-");
|
|
1244
|
+
nodeWriteFileSync(path.join(notAnExport, "README.md"), "hello");
|
|
1245
|
+
fixture = notAnExport;
|
|
1246
|
+
const req = new Request("http://x/import", {
|
|
1247
|
+
method: "POST",
|
|
1248
|
+
body: JSON.stringify({
|
|
1249
|
+
remote_url: "https://github.com/a/b.git",
|
|
1250
|
+
mode: "merge",
|
|
1251
|
+
credentials: { kind: "none" },
|
|
1252
|
+
}),
|
|
1253
|
+
});
|
|
1254
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(notAnExport));
|
|
1255
|
+
expect(res.status).toBe(400);
|
|
1256
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
1257
|
+
expect(body.error_type).toBe("not_a_vault_export");
|
|
1258
|
+
expect(body.message).toContain("vault.yaml");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1262
|
+
home = tmp("import-route-stored-creds-");
|
|
1263
|
+
await bootstrapVault(home);
|
|
1264
|
+
fixture = await buildExportFixture();
|
|
1265
|
+
// Write a stored PAT credential matching github.com.
|
|
1266
|
+
writeCredentials({
|
|
1267
|
+
active_method: "pat",
|
|
1268
|
+
github_oauth: null,
|
|
1269
|
+
pat: {
|
|
1270
|
+
token: "ghp_stored_token_xyz",
|
|
1271
|
+
remote_url: "https://x-access-token:ghp_stored_token_xyz@github.com/a/b.git",
|
|
1272
|
+
label: "stored",
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
// Assert the spawn argv carries the stored token (proves the
|
|
1276
|
+
// credentialsFile path resolved).
|
|
1277
|
+
let observedArgv: string[] | null = null;
|
|
1278
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
1279
|
+
observedArgv = argv;
|
|
1280
|
+
const destDir = argv[argv.length - 1]!;
|
|
1281
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
1282
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1283
|
+
};
|
|
1284
|
+
const req = new Request("http://x/import", {
|
|
1285
|
+
method: "POST",
|
|
1286
|
+
body: JSON.stringify({
|
|
1287
|
+
remote_url: "https://github.com/a/b.git",
|
|
1288
|
+
mode: "merge",
|
|
1289
|
+
credentials: null,
|
|
1290
|
+
}),
|
|
1291
|
+
});
|
|
1292
|
+
const res = await handleMirrorImport(req, "default", fakeSpawn);
|
|
1293
|
+
expect(res.status).toBe(200);
|
|
1294
|
+
// The clone URL should have the stored token embedded.
|
|
1295
|
+
expect(observedArgv).not.toBeNull();
|
|
1296
|
+
expect(observedArgv!.some((arg) => arg.includes("ghp_stored_token_xyz"))).toBe(true);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("per-call PAT override is used when supplied", async () => {
|
|
1300
|
+
home = tmp("import-route-per-call-pat-");
|
|
1301
|
+
await bootstrapVault(home);
|
|
1302
|
+
fixture = await buildExportFixture();
|
|
1303
|
+
// Stored credentials should be IGNORED when per-call PAT is supplied.
|
|
1304
|
+
writeCredentials({
|
|
1305
|
+
active_method: "pat",
|
|
1306
|
+
github_oauth: null,
|
|
1307
|
+
pat: {
|
|
1308
|
+
token: "ghp_stored_xyz",
|
|
1309
|
+
remote_url: "https://x-access-token:ghp_stored_xyz@github.com/a/b.git",
|
|
1310
|
+
label: "stored",
|
|
1311
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
let observedArgv: string[] | null = null;
|
|
1314
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
1315
|
+
observedArgv = argv;
|
|
1316
|
+
const destDir = argv[argv.length - 1]!;
|
|
1317
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
1318
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1319
|
+
};
|
|
1320
|
+
const req = new Request("http://x/import", {
|
|
1321
|
+
method: "POST",
|
|
1322
|
+
body: JSON.stringify({
|
|
1323
|
+
remote_url: "https://github.com/a/b.git",
|
|
1324
|
+
mode: "merge",
|
|
1325
|
+
credentials: { kind: "pat", token: "ghp_per_call_only" },
|
|
1326
|
+
}),
|
|
1327
|
+
});
|
|
1328
|
+
const res = await handleMirrorImport(req, "default", fakeSpawn);
|
|
1329
|
+
expect(res.status).toBe(200);
|
|
1330
|
+
expect(observedArgv).not.toBeNull();
|
|
1331
|
+
const joined = observedArgv!.join(" ");
|
|
1332
|
+
expect(joined).toContain("ghp_per_call_only");
|
|
1333
|
+
expect(joined).not.toContain("ghp_stored_xyz");
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|