routstrd 0.3.11 → 0.4.0

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,508 @@
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
+ import { gunzipSync } from "bun";
3
+ import {
4
+ existsSync,
5
+ mkdtempSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { tmpdir } from "os";
12
+ import { join } from "path";
13
+ import {
14
+ assertLegacyCocodNotRunning,
15
+ claimLegacyCocodPidFile,
16
+ createCocoClient,
17
+ stopLegacyCocod,
18
+ } from "./coco-client";
19
+
20
+ type GuardOptions = NonNullable<
21
+ Parameters<typeof assertLegacyCocodNotRunning>[0]
22
+ >;
23
+ type LegacyFetch = NonNullable<GuardOptions["fetchImpl"]>;
24
+
25
+ const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock";
26
+ const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid";
27
+ const tempDirs: string[] = [];
28
+
29
+ function makeTempDir(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-migration-test-"));
31
+ tempDirs.push(dir);
32
+ return dir;
33
+ }
34
+
35
+ afterEach(() => {
36
+ for (const dir of tempDirs.splice(0)) {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ function socketOnly(path: string): boolean {
42
+ return path === SOCKET_PATH;
43
+ }
44
+
45
+ describe("default mint functionality", () => {
46
+ it("automatically adds default mint when no mints exist", async () => {
47
+ const walletDir = join(makeTempDir(), "wallet");
48
+ mkdirSync(walletDir, { recursive: true });
49
+ writeFileSync(
50
+ join(walletDir, "config.json"),
51
+ JSON.stringify({
52
+ version: 1,
53
+ mnemonic:
54
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
55
+ encrypted: false,
56
+ }),
57
+ );
58
+
59
+ const client = await createCocoClient({
60
+ walletDir,
61
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
62
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
63
+ });
64
+ try {
65
+ expect(readFileSync(join(walletDir, "wallet.pid"), "utf8")).toBe(
66
+ String(process.pid),
67
+ );
68
+ expect(readFileSync(join(walletDir, "legacy-cocod.pid"), "utf8")).toBe(
69
+ String(process.pid),
70
+ );
71
+
72
+ const mints = await client.listMints();
73
+ expect(mints).toContain("https://mint.cubabitcoin.org");
74
+
75
+ const defaultMint = await client.getDefaultMint();
76
+ expect(defaultMint).toBe("https://mint.cubabitcoin.org");
77
+ } finally {
78
+ await client.dispose?.();
79
+ }
80
+ expect(existsSync(join(walletDir, "wallet.pid"))).toBe(false);
81
+ expect(existsSync(join(walletDir, "legacy-cocod.pid"))).toBe(false);
82
+ });
83
+
84
+ it("respects existing default mint in config", async () => {
85
+ const walletDir = join(makeTempDir(), "wallet");
86
+ const configuredDefault = "https://mint.cubabitcoin.org";
87
+ mkdirSync(walletDir, { recursive: true });
88
+
89
+ // Create config with an explicit defaultMint
90
+ writeFileSync(
91
+ join(walletDir, "config.json"),
92
+ JSON.stringify({
93
+ version: 1,
94
+ mnemonic:
95
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
96
+ encrypted: false,
97
+ defaultMintUrl: configuredDefault,
98
+ }),
99
+ );
100
+
101
+ const client = await createCocoClient({
102
+ walletDir,
103
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
104
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
105
+ });
106
+ try {
107
+ // Should respect the configured default mint
108
+ const defaultMint = await client.getDefaultMint();
109
+ expect(defaultMint).toBe(configuredDefault);
110
+
111
+ // The mint should have been auto-added as trusted
112
+ const mints = await client.listMints();
113
+ expect(mints).toContain(configuredDefault);
114
+ } finally {
115
+ await client.dispose?.();
116
+ }
117
+ });
118
+
119
+ it("allows setting default mint to an already trusted mint", async () => {
120
+ const walletDir = join(makeTempDir(), "wallet");
121
+ mkdirSync(walletDir, { recursive: true });
122
+ writeFileSync(
123
+ join(walletDir, "config.json"),
124
+ JSON.stringify({
125
+ version: 1,
126
+ mnemonic:
127
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
128
+ encrypted: false,
129
+ }),
130
+ );
131
+
132
+ const client = await createCocoClient({
133
+ walletDir,
134
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
135
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
136
+ });
137
+ try {
138
+ // The initial default should be the auto-added Cuba mint
139
+ const initialDefault = await client.getDefaultMint();
140
+ expect(initialDefault).toBe("https://mint.cubabitcoin.org");
141
+
142
+ // Setting the same mint should work
143
+ const message = await client.setDefaultMint(
144
+ "https://mint.cubabitcoin.org",
145
+ );
146
+ expect(message).toContain("https://mint.cubabitcoin.org");
147
+
148
+ const defaultMint = await client.getDefaultMint();
149
+ expect(defaultMint).toBe("https://mint.cubabitcoin.org");
150
+ } finally {
151
+ await client.dispose?.();
152
+ }
153
+ });
154
+ });
155
+
156
+ describe("legacy cocod wallet migration", () => {
157
+ it("opens an existing unencrypted config and preserves database balances", async () => {
158
+ const walletDir = join(makeTempDir(), "wallet");
159
+ const mintUrl = "https://mint.example.com";
160
+ mkdirSync(walletDir, { recursive: true });
161
+
162
+ // Note: Setting defaultMint to Cuba mint because the fixture database
163
+ // apparently doesn't preserve trusted mints correctly across coco-core versions,
164
+ // and we can't contact the example.com mint. The important part of this test
165
+ // is that balances are preserved, not the specific mint URL.
166
+ writeFileSync(
167
+ join(walletDir, "config.json"),
168
+ JSON.stringify({
169
+ version: 1,
170
+ mnemonic:
171
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
172
+ encrypted: false,
173
+ defaultMintUrl: "https://mint.cubabitcoin.org",
174
+ }),
175
+ );
176
+
177
+ // This fixture was generated with @routstr/cocod 0.0.24 using its
178
+ // coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents
179
+ // this test from accidentally creating its "legacy" database with the
180
+ // same current adapter that createCocoClient uses to read it.
181
+ const fixture = readFileSync(
182
+ join(import.meta.dir, "fixtures", "cocod-0.0.24-wallet.db.gz"),
183
+ );
184
+ writeFileSync(join(walletDir, "coco.db"), gunzipSync(fixture));
185
+
186
+ // NPC is disabled here: this test verifies database migration, and the
187
+ // real plugin would otherwise open a websocket to npubx.cash.
188
+ const client = await createCocoClient({
189
+ walletDir,
190
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
191
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
192
+ enableNpc: false,
193
+ });
194
+ try {
195
+ expect(await client.getStatus()).toBe("UNLOCKED");
196
+ expect(await client.getBalances()).toEqual({ [mintUrl]: 10 });
197
+ } finally {
198
+ await client.dispose?.();
199
+ }
200
+
201
+ // Prove the migrated schema remains reopenable and the pre-existing
202
+ // proofs survive a complete in-process wallet restart.
203
+ const reopenedClient = await createCocoClient({
204
+ walletDir,
205
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
206
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
207
+ enableNpc: false,
208
+ });
209
+ try {
210
+ expect(await reopenedClient.getBalances()).toEqual({ [mintUrl]: 10 });
211
+ } finally {
212
+ await reopenedClient.dispose?.();
213
+ }
214
+ });
215
+ });
216
+
217
+ describe("assertLegacyCocodNotRunning", () => {
218
+ it("does not probe when the legacy socket and PID file do not exist", async () => {
219
+ const fetchImpl = mock<LegacyFetch>(async () => new Response("pong"));
220
+
221
+ await assertLegacyCocodNotRunning({
222
+ socketPath: SOCKET_PATH,
223
+ pidFilePath: PID_FILE_PATH,
224
+ pathExists: () => false,
225
+ fetchImpl,
226
+ });
227
+
228
+ expect(fetchImpl).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it("refuses to continue when cocod responds on the legacy socket", async () => {
232
+ const fetchImpl = mock<LegacyFetch>(async () =>
233
+ Response.json({ output: "pong" }),
234
+ );
235
+
236
+ await expect(
237
+ assertLegacyCocodNotRunning({
238
+ socketPath: SOCKET_PATH,
239
+ pidFilePath: PID_FILE_PATH,
240
+ pathExists: socketOnly,
241
+ fetchImpl,
242
+ }),
243
+ ).rejects.toThrow("Legacy cocod daemon is still running");
244
+
245
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
246
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost/ping");
247
+ expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ unix: SOCKET_PATH });
248
+ });
249
+
250
+ it.each(["ENOENT", "ECONNREFUSED", "FailedToOpenSocket"])(
251
+ "allows startup for a stale socket that fails with %s",
252
+ async (code) => {
253
+ const fetchImpl = mock<LegacyFetch>(async () => {
254
+ throw Object.assign(new Error("socket unavailable"), { code });
255
+ });
256
+
257
+ await expect(
258
+ assertLegacyCocodNotRunning({
259
+ socketPath: SOCKET_PATH,
260
+ pidFilePath: PID_FILE_PATH,
261
+ pathExists: socketOnly,
262
+ fetchImpl,
263
+ }),
264
+ ).resolves.toBeUndefined();
265
+ },
266
+ );
267
+
268
+ it("recognizes stale socket errors nested under cause", async () => {
269
+ const fetchImpl = mock<LegacyFetch>(async () => {
270
+ throw new TypeError("fetch failed", {
271
+ cause: Object.assign(new Error("connection refused"), {
272
+ code: "ECONNREFUSED",
273
+ }),
274
+ });
275
+ });
276
+
277
+ await expect(
278
+ assertLegacyCocodNotRunning({
279
+ socketPath: SOCKET_PATH,
280
+ pidFilePath: PID_FILE_PATH,
281
+ pathExists: socketOnly,
282
+ fetchImpl,
283
+ }),
284
+ ).resolves.toBeUndefined();
285
+ });
286
+
287
+ it("refuses to continue when the legacy PID is still running", async () => {
288
+ const fetchImpl = mock<LegacyFetch>(async () =>
289
+ Response.json({ output: "pong" }),
290
+ );
291
+
292
+ await expect(
293
+ assertLegacyCocodNotRunning({
294
+ socketPath: SOCKET_PATH,
295
+ pidFilePath: PID_FILE_PATH,
296
+ pathExists: (path) => path === PID_FILE_PATH,
297
+ readFile: () => "4242\n",
298
+ isProcessRunning: (pid) => pid === 4242,
299
+ fetchImpl,
300
+ }),
301
+ ).rejects.toThrow("Legacy cocod daemon is still running with PID 4242");
302
+
303
+ expect(fetchImpl).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it("ignores a stale PID file when no socket exists", async () => {
307
+ await expect(
308
+ assertLegacyCocodNotRunning({
309
+ socketPath: SOCKET_PATH,
310
+ pidFilePath: PID_FILE_PATH,
311
+ pathExists: (path) => path === PID_FILE_PATH,
312
+ readFile: () => "4242\n",
313
+ isProcessRunning: () => false,
314
+ }),
315
+ ).resolves.toBeUndefined();
316
+ });
317
+
318
+ it("ignores the caller's own migration lock PID", async () => {
319
+ await expect(
320
+ assertLegacyCocodNotRunning({
321
+ socketPath: SOCKET_PATH,
322
+ pidFilePath: PID_FILE_PATH,
323
+ pathExists: (path) => path === PID_FILE_PATH,
324
+ readFile: () => "4242\n",
325
+ isProcessRunning: () => true,
326
+ ignorePid: 4242,
327
+ }),
328
+ ).resolves.toBeUndefined();
329
+ });
330
+
331
+ it("fails closed when the socket cannot be probed safely", async () => {
332
+ const fetchImpl = mock<LegacyFetch>(async () => {
333
+ throw Object.assign(new Error("permission denied"), { code: "EACCES" });
334
+ });
335
+
336
+ await expect(
337
+ assertLegacyCocodNotRunning({
338
+ socketPath: SOCKET_PATH,
339
+ pidFilePath: PID_FILE_PATH,
340
+ pathExists: socketOnly,
341
+ fetchImpl,
342
+ }),
343
+ ).rejects.toThrow("Cannot verify whether the legacy cocod daemon has stopped");
344
+ });
345
+ });
346
+
347
+ describe("stopLegacyCocod", () => {
348
+ it("stops a live PID only when legacy cocod responds on its socket", async () => {
349
+ let running = true;
350
+ const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {
351
+ running = false;
352
+ });
353
+ const fetchImpl = mock<LegacyFetch>(async () =>
354
+ Response.json({ output: "pong" }),
355
+ );
356
+
357
+ await stopLegacyCocod({
358
+ socketPath: SOCKET_PATH,
359
+ pidFilePath: PID_FILE_PATH,
360
+ pathExists: () => true,
361
+ readFile: () => "4242\n",
362
+ isProcessRunning: () => running,
363
+ fetchImpl,
364
+ killProcess,
365
+ pollIntervalMs: 1,
366
+ timeoutMs: 50,
367
+ });
368
+
369
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
370
+ expect(killProcess).toHaveBeenCalledWith(4242, "SIGTERM");
371
+ });
372
+
373
+ it("does not kill a routstrd PID that owns the shared pidfile with a stale socket", async () => {
374
+ const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {});
375
+ const fetchImpl = mock<LegacyFetch>(async () => {
376
+ throw Object.assign(new Error("socket unavailable"), {
377
+ code: "ECONNREFUSED",
378
+ });
379
+ });
380
+
381
+ await stopLegacyCocod({
382
+ socketPath: SOCKET_PATH,
383
+ pidFilePath: PID_FILE_PATH,
384
+ pathExists: () => true,
385
+ readFile: () => "4242\n",
386
+ isProcessRunning: () => true,
387
+ fetchImpl,
388
+ killProcess,
389
+ });
390
+
391
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
392
+ expect(killProcess).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("does not kill a live pidfile owner when no legacy socket exists", async () => {
396
+ const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {});
397
+ const fetchImpl = mock<LegacyFetch>(async () =>
398
+ Response.json({ output: "pong" }),
399
+ );
400
+
401
+ await stopLegacyCocod({
402
+ socketPath: SOCKET_PATH,
403
+ pidFilePath: PID_FILE_PATH,
404
+ pathExists: (path) => path === PID_FILE_PATH,
405
+ readFile: () => "4242\n",
406
+ isProcessRunning: () => true,
407
+ fetchImpl,
408
+ killProcess,
409
+ });
410
+
411
+ expect(fetchImpl).not.toHaveBeenCalled();
412
+ expect(killProcess).not.toHaveBeenCalled();
413
+ });
414
+ });
415
+
416
+ describe("claimLegacyCocodPidFile", () => {
417
+ it("claims the PID file exclusively and releases its own claim", () => {
418
+ let storedPid: string | undefined;
419
+ let removed = false;
420
+ const opened: Array<{ path: string; fd: number }> = [];
421
+
422
+ const release = claimLegacyCocodPidFile({
423
+ pidFilePath: PID_FILE_PATH,
424
+ pid: 4242,
425
+ openExclusive: (path) => {
426
+ opened.push({ path, fd: 7 });
427
+ return 7;
428
+ },
429
+ writePid: (fd, pid) => {
430
+ expect(fd).toBe(7);
431
+ storedPid = String(pid);
432
+ },
433
+ closeFile: (fd) => expect(fd).toBe(7),
434
+ readFile: () => storedPid || "",
435
+ removeFile: () => {
436
+ removed = true;
437
+ },
438
+ });
439
+
440
+ expect(opened).toEqual([{ path: PID_FILE_PATH, fd: 7 }]);
441
+ expect(storedPid).toBe("4242");
442
+ release();
443
+ expect(removed).toBe(true);
444
+ });
445
+
446
+ it("refuses startup when another process wins the atomic claim", () => {
447
+ expect(() =>
448
+ claimLegacyCocodPidFile({
449
+ pidFilePath: PID_FILE_PATH,
450
+ openExclusive: () => {
451
+ throw Object.assign(new Error("exists"), { code: "EEXIST" });
452
+ },
453
+ readFile: () => "4242",
454
+ isProcessRunning: () => true,
455
+ }),
456
+ ).toThrow("Another cocod or routstrd process may be starting");
457
+ });
458
+
459
+ it("replaces a confirmed stale PID file before claiming it", () => {
460
+ let openAttempts = 0;
461
+ let removed = false;
462
+ let storedPid = "4242";
463
+
464
+ const release = claimLegacyCocodPidFile({
465
+ pidFilePath: PID_FILE_PATH,
466
+ pid: 9001,
467
+ openExclusive: () => {
468
+ openAttempts++;
469
+ if (openAttempts === 1) {
470
+ throw Object.assign(new Error("exists"), { code: "EEXIST" });
471
+ }
472
+ return 7;
473
+ },
474
+ readFile: () => storedPid,
475
+ isProcessRunning: () => false,
476
+ removeFile: () => {
477
+ removed = true;
478
+ },
479
+ writePid: (_fd, pid) => {
480
+ storedPid = String(pid);
481
+ },
482
+ closeFile: () => {},
483
+ });
484
+
485
+ expect(openAttempts).toBe(2);
486
+ expect(removed).toBe(true);
487
+ expect(storedPid).toBe("9001");
488
+ release();
489
+ });
490
+
491
+ it("does not remove a PID file that no longer belongs to this process", () => {
492
+ let removed = false;
493
+ const release = claimLegacyCocodPidFile({
494
+ pidFilePath: PID_FILE_PATH,
495
+ pid: 4242,
496
+ openExclusive: () => 7,
497
+ writePid: () => {},
498
+ closeFile: () => {},
499
+ readFile: () => "9001",
500
+ removeFile: () => {
501
+ removed = true;
502
+ },
503
+ });
504
+
505
+ release();
506
+ expect(removed).toBe(false);
507
+ });
508
+ });