@openparachute/vault 0.4.6-rc.3 → 0.4.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Tests for the mirror config schema, parse/serialize, validation, and
3
+ * path resolution helpers (vault-sync Phase A1).
4
+ *
5
+ * All pure unit tests except the path-validation ones, which spawn `git`
6
+ * against tempdirs to exercise the real filesystem check.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ import {
15
+ defaultMirrorConfig,
16
+ parseMirrorConfig,
17
+ resolveMirrorPath,
18
+ serializeMirrorConfig,
19
+ validateExternalPath,
20
+ validateMirrorConfigShape,
21
+ } from "./mirror-config.ts";
22
+
23
+ function tmp(prefix: string): string {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
25
+ }
26
+
27
+ function initRepo(dir: string): void {
28
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
29
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
30
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Defaults
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe("defaultMirrorConfig", () => {
38
+ test("defaults to enabled=false — upgrading vaults see zero behavior change", () => {
39
+ const d = defaultMirrorConfig();
40
+ expect(d.enabled).toBe(false);
41
+ expect(d.location).toBe("internal");
42
+ expect(d.external_path).toBeNull();
43
+ expect(d.watch).toBe(false);
44
+ expect(d.auto_commit).toBe(true);
45
+ expect(d.auto_push).toBe(false);
46
+ expect(d.commit_template).toContain("{{date}}");
47
+ expect(d.interval_seconds).toBe(5);
48
+ });
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Parse + serialize
53
+ // ---------------------------------------------------------------------------
54
+
55
+ describe("parseMirrorConfig", () => {
56
+ test("returns undefined when no `mirror:` section is present", () => {
57
+ expect(parseMirrorConfig("port: 1940\n")).toBeUndefined();
58
+ expect(parseMirrorConfig("")).toBeUndefined();
59
+ });
60
+
61
+ test("parses a fully-specified mirror block", () => {
62
+ const yaml = [
63
+ "port: 1940",
64
+ "mirror:",
65
+ " enabled: true",
66
+ " location: external",
67
+ " external_path: /home/aaron/mirrors/gitcoin",
68
+ " watch: true",
69
+ " auto_commit: true",
70
+ " auto_push: true",
71
+ ' commit_template: "vault: {{notes_changed}} note{{plural}}"',
72
+ " interval_seconds: 10",
73
+ ].join("\n");
74
+ const m = parseMirrorConfig(yaml);
75
+ expect(m).toEqual({
76
+ enabled: true,
77
+ location: "external",
78
+ external_path: "/home/aaron/mirrors/gitcoin",
79
+ watch: true,
80
+ auto_commit: true,
81
+ auto_push: true,
82
+ commit_template: "vault: {{notes_changed}} note{{plural}}",
83
+ interval_seconds: 10,
84
+ });
85
+ });
86
+
87
+ test("partial mirror block fills missing fields from defaults", () => {
88
+ const yaml = "mirror:\n enabled: true\n watch: true\n";
89
+ const m = parseMirrorConfig(yaml)!;
90
+ expect(m.enabled).toBe(true);
91
+ expect(m.watch).toBe(true);
92
+ expect(m.location).toBe("internal");
93
+ expect(m.auto_commit).toBe(true);
94
+ });
95
+
96
+ test("external_path: null is interpreted as null", () => {
97
+ const m = parseMirrorConfig(
98
+ "mirror:\n enabled: true\n external_path: null\n",
99
+ )!;
100
+ expect(m.external_path).toBeNull();
101
+ });
102
+
103
+ test("stops at next top-level key", () => {
104
+ const yaml = [
105
+ "mirror:",
106
+ " enabled: true",
107
+ " location: external",
108
+ " external_path: /a/b",
109
+ "port: 1940",
110
+ ].join("\n");
111
+ const m = parseMirrorConfig(yaml)!;
112
+ expect(m.external_path).toBe("/a/b");
113
+ expect(m.enabled).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("serializeMirrorConfig", () => {
118
+ test("round-trips through parseMirrorConfig", () => {
119
+ const original = {
120
+ enabled: true,
121
+ location: "external" as const,
122
+ external_path: "/home/aaron/team-brain",
123
+ watch: true,
124
+ auto_commit: true,
125
+ auto_push: false,
126
+ commit_template: "export: {{date}} ({{notes_changed}} note{{plural}})",
127
+ interval_seconds: 5,
128
+ };
129
+ const yaml = serializeMirrorConfig(original).join("\n") + "\n";
130
+ const parsed = parseMirrorConfig(yaml);
131
+ expect(parsed).toEqual(original);
132
+ });
133
+
134
+ test("serializes null external_path explicitly", () => {
135
+ const lines = serializeMirrorConfig({
136
+ ...defaultMirrorConfig(),
137
+ enabled: true,
138
+ });
139
+ expect(lines.some((l) => l === " external_path: null")).toBe(true);
140
+ });
141
+
142
+ test("quotes paths with colons or hashes", () => {
143
+ const lines = serializeMirrorConfig({
144
+ ...defaultMirrorConfig(),
145
+ enabled: true,
146
+ location: "external",
147
+ external_path: "/path/with: colon",
148
+ });
149
+ expect(lines.some((l) => l.includes('"/path/with: colon"'))).toBe(true);
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Path resolution
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe("resolveMirrorPath", () => {
158
+ test("internal resolves to <vaultDataDir>/mirror", () => {
159
+ const p = resolveMirrorPath("/var/data/default", {
160
+ ...defaultMirrorConfig(),
161
+ enabled: true,
162
+ location: "internal",
163
+ });
164
+ expect(p).toBe("/var/data/default/mirror");
165
+ });
166
+
167
+ test("external returns the operator path verbatim", () => {
168
+ const p = resolveMirrorPath("/ignored", {
169
+ ...defaultMirrorConfig(),
170
+ enabled: true,
171
+ location: "external",
172
+ external_path: "/home/aaron/notes",
173
+ });
174
+ expect(p).toBe("/home/aaron/notes");
175
+ });
176
+
177
+ test("external + no path → null (manager treats as soft-disabled)", () => {
178
+ const p = resolveMirrorPath("/ignored", {
179
+ ...defaultMirrorConfig(),
180
+ enabled: true,
181
+ location: "external",
182
+ external_path: null,
183
+ });
184
+ expect(p).toBeNull();
185
+ });
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Shape validation
190
+ // ---------------------------------------------------------------------------
191
+
192
+ describe("validateMirrorConfigShape", () => {
193
+ test("accepts the minimal shape (empty object → defaults)", () => {
194
+ const r = validateMirrorConfigShape({});
195
+ expect(r.ok).toBe(true);
196
+ if (r.ok) {
197
+ expect(r.config).toEqual(defaultMirrorConfig());
198
+ }
199
+ });
200
+
201
+ test("rejects non-objects", () => {
202
+ expect(validateMirrorConfigShape(null).ok).toBe(false);
203
+ expect(validateMirrorConfigShape("hi").ok).toBe(false);
204
+ expect(validateMirrorConfigShape(42).ok).toBe(false);
205
+ });
206
+
207
+ test("rejects unknown location", () => {
208
+ const r = validateMirrorConfigShape({ location: "wherever" });
209
+ expect(r.ok).toBe(false);
210
+ if (!r.ok) expect(r.field).toBe("location");
211
+ });
212
+
213
+ test("rejects external + missing external_path", () => {
214
+ const r = validateMirrorConfigShape({
215
+ enabled: true,
216
+ location: "external",
217
+ });
218
+ expect(r.ok).toBe(false);
219
+ if (!r.ok) expect(r.field).toBe("external_path");
220
+ });
221
+
222
+ test("accepts external + missing external_path when disabled (operator turning off a broken mirror)", () => {
223
+ // Regression for the reviewer-flagged disable-only case: an operator
224
+ // PUTting `{enabled: false, location: "external"}` (no path) must
225
+ // succeed. Disable should never fail validation on path issues.
226
+ const r = validateMirrorConfigShape({
227
+ enabled: false,
228
+ location: "external",
229
+ });
230
+ expect(r.ok).toBe(true);
231
+ if (r.ok) {
232
+ expect(r.config.enabled).toBe(false);
233
+ expect(r.config.location).toBe("external");
234
+ expect(r.config.external_path).toBeNull();
235
+ }
236
+ });
237
+
238
+ test("accepts external + external_path", () => {
239
+ const r = validateMirrorConfigShape({
240
+ enabled: true,
241
+ location: "external",
242
+ external_path: "/tmp/foo",
243
+ });
244
+ expect(r.ok).toBe(true);
245
+ if (r.ok) expect(r.config.external_path).toBe("/tmp/foo");
246
+ });
247
+
248
+ test("rejects non-boolean enabled", () => {
249
+ const r = validateMirrorConfigShape({ enabled: "yes" });
250
+ expect(r.ok).toBe(false);
251
+ if (!r.ok) expect(r.field).toBe("enabled");
252
+ });
253
+
254
+ test("rejects non-integer interval_seconds", () => {
255
+ const r = validateMirrorConfigShape({ interval_seconds: 0.5 });
256
+ expect(r.ok).toBe(false);
257
+ if (!r.ok) expect(r.field).toBe("interval_seconds");
258
+ });
259
+
260
+ test("rejects empty commit_template", () => {
261
+ const r = validateMirrorConfigShape({ commit_template: " " });
262
+ expect(r.ok).toBe(false);
263
+ if (!r.ok) expect(r.field).toBe("commit_template");
264
+ });
265
+
266
+ test("trims external_path whitespace; with internal location empty trim → null", () => {
267
+ const r = validateMirrorConfigShape({
268
+ enabled: false,
269
+ location: "internal",
270
+ external_path: " ",
271
+ });
272
+ expect(r.ok).toBe(true);
273
+ if (r.ok) expect(r.config.external_path).toBeNull();
274
+ });
275
+
276
+ test("trims external_path whitespace on non-empty value", () => {
277
+ const r = validateMirrorConfigShape({
278
+ enabled: true,
279
+ location: "external",
280
+ external_path: " /tmp/foo ",
281
+ });
282
+ expect(r.ok).toBe(true);
283
+ if (r.ok) expect(r.config.external_path).toBe("/tmp/foo");
284
+ });
285
+ });
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // validateExternalPath — filesystem-touching
289
+ // ---------------------------------------------------------------------------
290
+
291
+ describe("validateExternalPath", () => {
292
+ let dir: string;
293
+ afterEach(() => {
294
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
295
+ });
296
+
297
+ test("rejects missing path with actionable error", async () => {
298
+ dir = tmp("mirror-validate-");
299
+ const missing = path.join(dir, "nope");
300
+ const r = await validateExternalPath(missing);
301
+ expect(r.ok).toBe(false);
302
+ if (!r.ok) expect(r.error).toContain("doesn't exist");
303
+ });
304
+
305
+ test("rejects path-is-a-file", async () => {
306
+ dir = tmp("mirror-validate-file-");
307
+ const file = path.join(dir, "f.txt");
308
+ fs.writeFileSync(file, "x");
309
+ const r = await validateExternalPath(file);
310
+ expect(r.ok).toBe(false);
311
+ if (!r.ok) expect(r.error).toContain("isn't a directory");
312
+ });
313
+
314
+ test("rejects existing-dir but not a git repo", async () => {
315
+ dir = tmp("mirror-validate-nogit-");
316
+ const r = await validateExternalPath(dir);
317
+ expect(r.ok).toBe(false);
318
+ if (!r.ok) expect(r.error).toContain("isn't a git repository");
319
+ });
320
+
321
+ test("accepts existing-dir git repo", async () => {
322
+ dir = tmp("mirror-validate-git-");
323
+ initRepo(dir);
324
+ const r = await validateExternalPath(dir);
325
+ expect(r.ok).toBe(true);
326
+ if (r.ok) expect(r.resolved_path).toBe(dir);
327
+ });
328
+ });