@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Tests for mirror-credentials.ts — the UI-configurable git push
3
+ * credential store.
4
+ *
5
+ * Coverage:
6
+ * - Read/write round-trip (parse + serialize symmetric across realistic
7
+ * credential shapes).
8
+ * - File perms enforced at 0o600 (the storage layer's whole security
9
+ * model rides on this).
10
+ * - Redaction masks tokens consistently — `sanitizeCredentials` is what
11
+ * the HTTP responses + logs go through.
12
+ * - Idempotent delete (Disconnect should succeed even if the file is
13
+ * gone).
14
+ * - Git remote helpers add/replace `origin` idempotently and don't leak
15
+ * the token in stderr/stdout we surface back to callers.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+
23
+ import {
24
+ applyToGitRemote,
25
+ deleteCredentials,
26
+ emptyCredentials,
27
+ githubAuthedRemoteUrl,
28
+ legacyServerWideCredentialsPath,
29
+ migrateLegacyServerWideCredentials,
30
+ mirrorCredentialsPath,
31
+ parseCredentials,
32
+ previewToken,
33
+ readCredentials,
34
+ redactRemoteUrl,
35
+ sanitizeCredentials,
36
+ serializeCredentials,
37
+ unsetGitRemote,
38
+ writeCredentials,
39
+ type MirrorCredentials,
40
+ } from "./mirror-credentials.ts";
41
+
42
+ const ORIG_HOME = process.env.HOME;
43
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
44
+
45
+ function tmp(prefix: string): string {
46
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
47
+ }
48
+
49
+ function withSandbox<T>(fn: (home: string) => T): T {
50
+ const home = tmp("mirror-creds-");
51
+ fs.mkdirSync(path.join(home, "vault"), { recursive: true });
52
+ process.env.PARACHUTE_HOME = home;
53
+ process.env.HOME = home;
54
+ try {
55
+ return fn(home);
56
+ } finally {
57
+ fs.rmSync(home, { recursive: true, force: true });
58
+ }
59
+ }
60
+
61
+ afterEach(() => {
62
+ if (ORIG_HOME === undefined) delete process.env.HOME;
63
+ else process.env.HOME = ORIG_HOME;
64
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
65
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Defaults
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("emptyCredentials", () => {
73
+ test("returns a fully-null shape — no active method, no provider blocks", () => {
74
+ const e = emptyCredentials();
75
+ expect(e.active_method).toBeNull();
76
+ expect(e.github_oauth).toBeNull();
77
+ expect(e.pat).toBeNull();
78
+ });
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Serialize / parse round-trip
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("serialize + parse round-trip", () => {
86
+ test("empty credentials round-trip cleanly", () => {
87
+ const out = parseCredentials(serializeCredentials(emptyCredentials()));
88
+ expect(out).toEqual(emptyCredentials());
89
+ });
90
+
91
+ test("github_oauth credentials round-trip", () => {
92
+ const creds: MirrorCredentials = {
93
+ active_method: "github_oauth",
94
+ github_oauth: {
95
+ access_token: "gho_abc123def456ghi789",
96
+ scope: "repo",
97
+ authorized_at: "2026-05-28T03:14:15.000Z",
98
+ user_login: "aaron",
99
+ user_id: 12345,
100
+ },
101
+ pat: null,
102
+ };
103
+ const out = parseCredentials(serializeCredentials(creds));
104
+ expect(out).toEqual(creds);
105
+ });
106
+
107
+ test("pat credentials round-trip", () => {
108
+ const creds: MirrorCredentials = {
109
+ active_method: "pat",
110
+ github_oauth: null,
111
+ pat: {
112
+ token: "ghp_xyz9876543210abc",
113
+ remote_url: "https://github.com/aaron/my-vault.git",
114
+ label: "GitHub PAT",
115
+ },
116
+ };
117
+ const out = parseCredentials(serializeCredentials(creds));
118
+ expect(out).toEqual(creds);
119
+ });
120
+
121
+ test("both surfaces populated, github_oauth active", () => {
122
+ // The operator can have BOTH a github_oauth and a pat configured
123
+ // (e.g. linked GitHub, plus a fallback PAT). Only one is the
124
+ // active_method at any time; the others stay populated so flipping
125
+ // back is a single click rather than re-authenticating.
126
+ const creds: MirrorCredentials = {
127
+ active_method: "github_oauth",
128
+ github_oauth: {
129
+ access_token: "gho_test",
130
+ scope: "repo",
131
+ authorized_at: "2026-05-28T03:14:15.000Z",
132
+ user_login: "aaron",
133
+ user_id: 1,
134
+ },
135
+ pat: {
136
+ token: "ghp_test",
137
+ remote_url: "https://github.com/aaron/backup.git",
138
+ label: "Backup PAT",
139
+ },
140
+ };
141
+ // Short tokens get the < 12 char branch for previewToken; pad these
142
+ // up so the round-trip path is exercising the realistic shape.
143
+ creds.github_oauth!.access_token = "gho_abc123def456ghi789";
144
+ creds.pat!.token = "ghp_xyz9876543210abc";
145
+ const out = parseCredentials(serializeCredentials(creds));
146
+ expect(out).toEqual(creds);
147
+ });
148
+
149
+ test("special characters in label are quoted/escaped on round-trip", () => {
150
+ const creds: MirrorCredentials = {
151
+ active_method: "pat",
152
+ github_oauth: null,
153
+ pat: {
154
+ token: "ghp_test1234567890abcdef",
155
+ remote_url: "https://gitlab.com/team/repo.git",
156
+ label: 'PAT with "quotes" and: colons',
157
+ },
158
+ };
159
+ const out = parseCredentials(serializeCredentials(creds));
160
+ expect(out.pat!.label).toBe('PAT with "quotes" and: colons');
161
+ });
162
+
163
+ // Reviewer-flagged on vault#384 — without newline escaping a label
164
+ // containing a literal `\n` produces multi-line YAML that breaks
165
+ // the per-line section parser. PAT/OAuth tokens never carry newlines
166
+ // but operator-supplied labels have no such guarantee.
167
+ test("newlines + carriage returns in label round-trip via escapes", () => {
168
+ const labelWithLineBreak = "Line one\nLine two";
169
+ const labelWithCR = "First\rSecond";
170
+ for (const label of [labelWithLineBreak, labelWithCR]) {
171
+ const creds: MirrorCredentials = {
172
+ active_method: "pat",
173
+ github_oauth: null,
174
+ pat: {
175
+ token: "ghp_newline_test_token_12345",
176
+ remote_url: "https://gitlab.com/team/repo.git",
177
+ label,
178
+ },
179
+ };
180
+ const serialized = serializeCredentials(creds);
181
+ // Serialized YAML must NOT contain literal newlines inside the
182
+ // label scalar (would break section parsing).
183
+ const labelLine = serialized.split("\n").find((l) => l.includes("label:"))!;
184
+ expect(labelLine).not.toContain("\n");
185
+ // Round-trip recovers the original.
186
+ const out = parseCredentials(serialized);
187
+ expect(out.pat!.label).toBe(label);
188
+ }
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // File I/O
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("readCredentials / writeCredentials", () => {
197
+ test("returns null when no file exists", () => {
198
+ withSandbox(() => {
199
+ expect(readCredentials("default")).toBeNull();
200
+ });
201
+ });
202
+
203
+ test("write + read round-trip with real disk", () => {
204
+ withSandbox(() => {
205
+ const creds: MirrorCredentials = {
206
+ active_method: "github_oauth",
207
+ github_oauth: {
208
+ access_token: "gho_abc123def456ghi789",
209
+ scope: "repo",
210
+ authorized_at: "2026-05-28T03:14:15.000Z",
211
+ user_login: "aaron",
212
+ user_id: 12345,
213
+ },
214
+ pat: null,
215
+ };
216
+ writeCredentials("default", creds);
217
+ const read = readCredentials("default");
218
+ expect(read).toEqual(creds);
219
+ });
220
+ });
221
+
222
+ test("write enforces 0600 perms — the storage model rides on this", () => {
223
+ withSandbox(() => {
224
+ const creds: MirrorCredentials = {
225
+ ...emptyCredentials(),
226
+ active_method: "pat",
227
+ pat: {
228
+ token: "ghp_test1234567890abcdef",
229
+ remote_url: "https://github.com/x/y.git",
230
+ label: "test",
231
+ },
232
+ };
233
+ writeCredentials("default", creds);
234
+ const p = mirrorCredentialsPath("default");
235
+ const stat = fs.statSync(p);
236
+ const perms = stat.mode & 0o777;
237
+ expect(perms).toBe(0o600);
238
+ });
239
+ });
240
+
241
+ test("write creates the parent directory if missing", () => {
242
+ const home = tmp("mirror-creds-mkdir-");
243
+ process.env.PARACHUTE_HOME = home;
244
+ process.env.HOME = home;
245
+ try {
246
+ // Note: don't pre-create the vault data subdir.
247
+ const creds: MirrorCredentials = { ...emptyCredentials() };
248
+ writeCredentials("default", creds);
249
+ expect(
250
+ fs.existsSync(
251
+ path.join(home, "vault", "data", "default", ".mirror-credentials.yaml"),
252
+ ),
253
+ ).toBe(true);
254
+ } finally {
255
+ fs.rmSync(home, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test("two vaults' credentials are isolated — no bleed (vault#399)", () => {
260
+ withSandbox(() => {
261
+ const aCreds: MirrorCredentials = {
262
+ ...emptyCredentials(),
263
+ active_method: "pat",
264
+ pat: {
265
+ token: "ghp_vaultA1234567890abc",
266
+ remote_url: "https://x-access-token:ghp_vaultA1234567890abc@github.com/aaron/vault-a.git",
267
+ label: "vault A",
268
+ },
269
+ };
270
+ const bCreds: MirrorCredentials = {
271
+ ...emptyCredentials(),
272
+ active_method: "pat",
273
+ pat: {
274
+ token: "ghp_vaultB9876543210xyz",
275
+ remote_url: "https://x-access-token:ghp_vaultB9876543210xyz@github.com/aaron/vault-b.git",
276
+ label: "vault B",
277
+ },
278
+ };
279
+ writeCredentials("alpha", aCreds);
280
+ writeCredentials("beta", bCreds);
281
+ // Each vault reads back ITS OWN remote + token — never the other's.
282
+ expect(readCredentials("alpha")).toEqual(aCreds);
283
+ expect(readCredentials("beta")).toEqual(bCreds);
284
+ expect(readCredentials("alpha")?.pat?.remote_url).toContain("vault-a.git");
285
+ expect(readCredentials("beta")?.pat?.remote_url).toContain("vault-b.git");
286
+ expect(readCredentials("alpha")?.pat?.token).not.toBe(readCredentials("beta")?.pat?.token);
287
+ // Files live in separate per-vault dirs.
288
+ expect(mirrorCredentialsPath("alpha")).not.toBe(mirrorCredentialsPath("beta"));
289
+ });
290
+ });
291
+
292
+ test("write is atomic — partial file not left on failure path", () => {
293
+ withSandbox(() => {
294
+ const creds: MirrorCredentials = {
295
+ active_method: "github_oauth",
296
+ github_oauth: {
297
+ access_token: "gho_initial1234567890",
298
+ scope: "repo",
299
+ authorized_at: "2026-05-28T03:14:15.000Z",
300
+ user_login: "aaron",
301
+ user_id: 1,
302
+ },
303
+ pat: null,
304
+ };
305
+ writeCredentials("default", creds);
306
+ // Subsequent write replaces atomically (.tmp rename onto final).
307
+ const updated: MirrorCredentials = {
308
+ ...creds,
309
+ github_oauth: { ...creds.github_oauth!, access_token: "gho_updated123456789" },
310
+ };
311
+ writeCredentials("default", updated);
312
+ const read = readCredentials("default");
313
+ expect(read?.github_oauth?.access_token).toBe("gho_updated123456789");
314
+ // No leftover .tmp file.
315
+ expect(fs.existsSync(`${mirrorCredentialsPath("default")}.tmp`)).toBe(false);
316
+ });
317
+ });
318
+ });
319
+
320
+ describe("deleteCredentials", () => {
321
+ test("idempotent — missing file is a no-op (doesn't throw)", () => {
322
+ withSandbox(() => {
323
+ expect(() => deleteCredentials("default")).not.toThrow();
324
+ });
325
+ });
326
+
327
+ test("removes the file when present", () => {
328
+ withSandbox(() => {
329
+ writeCredentials("default", { ...emptyCredentials(), active_method: null });
330
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(true);
331
+ deleteCredentials("default");
332
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(false);
333
+ });
334
+ });
335
+ });
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Migration — legacy server-wide → per-vault (vault#399)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe("migrateLegacyServerWideCredentials", () => {
342
+ function writeLegacyFile(_home: string, creds: MirrorCredentials): string {
343
+ const legacyPath = legacyServerWideCredentialsPath();
344
+ fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
345
+ fs.writeFileSync(legacyPath, serializeCredentials(creds), { mode: 0o600 });
346
+ return legacyPath;
347
+ }
348
+
349
+ const sampleCreds: MirrorCredentials = {
350
+ ...emptyCredentials(),
351
+ active_method: "pat",
352
+ pat: {
353
+ token: "ghp_legacy1234567890abc",
354
+ remote_url: "https://x-access-token:ghp_legacy1234567890abc@github.com/aaron/first-vault.git",
355
+ label: "legacy",
356
+ },
357
+ };
358
+
359
+ test("no legacy file → no-op", () => {
360
+ withSandbox(() => {
361
+ const r = migrateLegacyServerWideCredentials("default");
362
+ expect(r.migrated).toBe(false);
363
+ expect((r as { reason: string }).reason).toBe("no_legacy_file");
364
+ });
365
+ });
366
+
367
+ test("legacy file → attributed to the FIRST vault, others stay empty", () => {
368
+ const home = tmp("mirror-migrate-");
369
+ process.env.PARACHUTE_HOME = home;
370
+ process.env.HOME = home;
371
+ try {
372
+ const legacyPath = writeLegacyFile(home, sampleCreds);
373
+ const r = migrateLegacyServerWideCredentials("first");
374
+ expect(r.migrated).toBe(true);
375
+ // First vault now owns the legacy creds.
376
+ expect(readCredentials("first")).toEqual(sampleCreds);
377
+ // A second vault that never had its own file starts EMPTY — the bug
378
+ // would have made it read the same remote. It must not.
379
+ expect(readCredentials("second")).toBeNull();
380
+ // Legacy file preserved as .bak — nothing silently lost.
381
+ expect(fs.existsSync(legacyPath)).toBe(false);
382
+ expect(fs.existsSync(`${legacyPath}.bak`)).toBe(true);
383
+ } finally {
384
+ fs.rmSync(home, { recursive: true, force: true });
385
+ }
386
+ });
387
+
388
+ test("idempotent — second run is a no-op once target has creds", () => {
389
+ const home = tmp("mirror-migrate-idem-");
390
+ process.env.PARACHUTE_HOME = home;
391
+ process.env.HOME = home;
392
+ try {
393
+ writeLegacyFile(home, sampleCreds);
394
+ expect(migrateLegacyServerWideCredentials("first").migrated).toBe(true);
395
+ // Run again: no legacy file remains, so no-op.
396
+ const r2 = migrateLegacyServerWideCredentials("first");
397
+ expect(r2.migrated).toBe(false);
398
+ // First vault's creds unchanged.
399
+ expect(readCredentials("first")).toEqual(sampleCreds);
400
+ } finally {
401
+ fs.rmSync(home, { recursive: true, force: true });
402
+ }
403
+ });
404
+
405
+ test("does not clobber an existing per-vault file", () => {
406
+ const home = tmp("mirror-migrate-noclobber-");
407
+ process.env.PARACHUTE_HOME = home;
408
+ process.env.HOME = home;
409
+ try {
410
+ writeLegacyFile(home, sampleCreds);
411
+ // Target vault already has its own (different) creds.
412
+ const existing: MirrorCredentials = {
413
+ ...emptyCredentials(),
414
+ active_method: "pat",
415
+ pat: {
416
+ token: "ghp_already1234567890ab",
417
+ remote_url: "https://x-access-token:ghp_already1234567890ab@github.com/aaron/already.git",
418
+ label: "already",
419
+ },
420
+ };
421
+ writeCredentials("first", existing);
422
+ const r = migrateLegacyServerWideCredentials("first");
423
+ expect(r.migrated).toBe(false);
424
+ expect((r as { reason: string }).reason).toBe("target_already_has_creds");
425
+ // Existing per-vault creds preserved, NOT overwritten by legacy.
426
+ expect(readCredentials("first")).toEqual(existing);
427
+ } finally {
428
+ fs.rmSync(home, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ test("no target vault → leaves legacy file in place for a later boot", () => {
433
+ const home = tmp("mirror-migrate-notarget-");
434
+ process.env.PARACHUTE_HOME = home;
435
+ process.env.HOME = home;
436
+ try {
437
+ const legacyPath = writeLegacyFile(home, sampleCreds);
438
+ const r = migrateLegacyServerWideCredentials(null);
439
+ expect(r.migrated).toBe(false);
440
+ expect((r as { reason: string }).reason).toBe("no_target_vault");
441
+ // Legacy file untouched — a future boot (after a vault exists) migrates.
442
+ expect(fs.existsSync(legacyPath)).toBe(true);
443
+ } finally {
444
+ fs.rmSync(home, { recursive: true, force: true });
445
+ }
446
+ });
447
+ });
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Redaction
451
+ // ---------------------------------------------------------------------------
452
+
453
+ describe("previewToken", () => {
454
+ test("masks middle of typical OAuth tokens", () => {
455
+ expect(previewToken("gho_abcdefghijklmnop")).toBe("gho_…mnop");
456
+ });
457
+
458
+ test("fully masks tokens shorter than 12 chars (defense in depth)", () => {
459
+ expect(previewToken("short")).toBe("***");
460
+ expect(previewToken("")).toBe("***");
461
+ });
462
+
463
+ test("preserves enough to verify but not enough to use", () => {
464
+ const t = "ghp_1234567890abcdefghij";
465
+ const p = previewToken(t);
466
+ // Has the prefix + the trailing chars.
467
+ expect(p.startsWith("ghp_")).toBe(true);
468
+ expect(p.endsWith("ghij")).toBe(true);
469
+ // Doesn't contain the middle.
470
+ expect(p).not.toContain("567890abc");
471
+ });
472
+ });
473
+
474
+ describe("redactRemoteUrl", () => {
475
+ test("strips userinfo from HTTPS URLs", () => {
476
+ expect(
477
+ redactRemoteUrl("https://x-access-token:gho_secret@github.com/a/b.git"),
478
+ ).toContain("***@github.com/a/b.git");
479
+ expect(
480
+ redactRemoteUrl("https://x-access-token:gho_secret@github.com/a/b.git"),
481
+ ).not.toContain("gho_secret");
482
+ });
483
+
484
+ test("leaves URLs without userinfo unchanged", () => {
485
+ expect(redactRemoteUrl("https://github.com/a/b.git")).toBe(
486
+ "https://github.com/a/b.git",
487
+ );
488
+ });
489
+
490
+ test("non-URL string fully masks (defense in depth)", () => {
491
+ expect(redactRemoteUrl("not a url")).toBe("***");
492
+ });
493
+ });
494
+
495
+ describe("sanitizeCredentials", () => {
496
+ test("null input returns the fully-null public shape", () => {
497
+ expect(sanitizeCredentials(null)).toEqual({
498
+ active_method: null,
499
+ github_oauth: null,
500
+ pat: null,
501
+ });
502
+ });
503
+
504
+ test("github_oauth credential exposes user info but masks token", () => {
505
+ const creds: MirrorCredentials = {
506
+ active_method: "github_oauth",
507
+ github_oauth: {
508
+ access_token: "gho_secret1234567890",
509
+ scope: "repo",
510
+ authorized_at: "2026-05-28T03:14:15.000Z",
511
+ user_login: "aaron",
512
+ user_id: 12345,
513
+ },
514
+ pat: null,
515
+ };
516
+ const safe = sanitizeCredentials(creds);
517
+ expect(safe.github_oauth?.user_login).toBe("aaron");
518
+ expect(safe.github_oauth?.user_id).toBe(12345);
519
+ expect(safe.github_oauth?.scope).toBe("repo");
520
+ expect(safe.github_oauth?.token_preview).toBe("gho_…7890");
521
+ // No raw token leaks.
522
+ expect(JSON.stringify(safe)).not.toContain("gho_secret");
523
+ });
524
+
525
+ test("pat credential masks both token AND any token embedded in remote_url", () => {
526
+ const creds: MirrorCredentials = {
527
+ active_method: "pat",
528
+ github_oauth: null,
529
+ pat: {
530
+ token: "ghp_secret1234567890",
531
+ remote_url:
532
+ "https://x-access-token:ghp_secret1234567890@github.com/owner/repo.git",
533
+ label: "GitHub PAT",
534
+ },
535
+ };
536
+ const safe = sanitizeCredentials(creds);
537
+ expect(safe.pat?.label).toBe("GitHub PAT");
538
+ expect(safe.pat?.token_preview).toBe("ghp_…7890");
539
+ // Embedded token in URL must be redacted.
540
+ expect(safe.pat?.remote_url).not.toContain("ghp_secret");
541
+ expect(JSON.stringify(safe)).not.toContain("ghp_secret");
542
+ });
543
+ });
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Git remote helpers (real git, real tempdir)
547
+ // ---------------------------------------------------------------------------
548
+
549
+ describe("githubAuthedRemoteUrl", () => {
550
+ test("builds the x-access-token https URL", () => {
551
+ expect(
552
+ githubAuthedRemoteUrl("gho_test", "aaron", "my-vault"),
553
+ ).toBe("https://x-access-token:gho_test@github.com/aaron/my-vault.git");
554
+ });
555
+ });
556
+
557
+ describe("applyToGitRemote + unsetGitRemote", () => {
558
+ let dir: string;
559
+
560
+ beforeEach(() => {
561
+ dir = tmp("mirror-creds-remote-");
562
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
563
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
564
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
565
+ });
566
+
567
+ afterEach(() => {
568
+ fs.rmSync(dir, { recursive: true, force: true });
569
+ });
570
+
571
+ test("adds origin when no remote configured", async () => {
572
+ const url = "https://x-access-token:gho_test1234567890@github.com/a/b.git";
573
+ const res = await applyToGitRemote(dir, url);
574
+ expect(res.ok).toBe(true);
575
+ const probe = Bun.spawnSync(["git", "remote", "get-url", "origin"], { cwd: dir });
576
+ expect(probe.exitCode).toBe(0);
577
+ expect(new TextDecoder().decode(probe.stdout).trim()).toBe(url);
578
+ });
579
+
580
+ test("replaces origin when already configured (idempotent rotation)", async () => {
581
+ Bun.spawnSync(["git", "remote", "add", "origin", "https://old.example/x.git"], { cwd: dir });
582
+ const newUrl = "https://x-access-token:gho_new1234567890@github.com/c/d.git";
583
+ const res = await applyToGitRemote(dir, newUrl);
584
+ expect(res.ok).toBe(true);
585
+ const probe = Bun.spawnSync(["git", "remote", "get-url", "origin"], { cwd: dir });
586
+ expect(new TextDecoder().decode(probe.stdout).trim()).toBe(newUrl);
587
+ });
588
+
589
+ test("unsetGitRemote removes origin", async () => {
590
+ Bun.spawnSync(["git", "remote", "add", "origin", "https://x.example/a.git"], { cwd: dir });
591
+ const res = await unsetGitRemote(dir);
592
+ expect(res.ok).toBe(true);
593
+ const probe = Bun.spawnSync(["git", "remote", "get-url", "origin"], { cwd: dir });
594
+ expect(probe.exitCode).not.toBe(0); // no remote
595
+ });
596
+
597
+ test("unsetGitRemote is idempotent — no remote = ok", async () => {
598
+ const res = await unsetGitRemote(dir);
599
+ expect(res.ok).toBe(true);
600
+ });
601
+ });