test(wallet): verify legacy cocod database migration

This commit is contained in:
redshift
2026-07-13 20:17:14 +08:00
parent 0b63ad31bf
commit 2d7c7e9b24
3 changed files with 91 additions and 15 deletions
+62 -1
View File
@@ -1,7 +1,12 @@
import { describe, expect, it, mock } from "bun:test";
import { afterEach, describe, expect, it, mock } from "bun:test";
import { gunzipSync } from "bun";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import {
assertLegacyCocodNotRunning,
claimLegacyCocodPidFile,
createCocoClient,
} from "./coco-client";
type GuardOptions = NonNullable<
@@ -11,11 +16,67 @@ type LegacyFetch = NonNullable<GuardOptions["fetchImpl"]>;
const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock";
const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-migration-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function socketOnly(path: string): boolean {
return path === SOCKET_PATH;
}
describe("legacy cocod wallet migration", () => {
it("opens an existing unencrypted config and preserves database balances", async () => {
const walletDir = join(makeTempDir(), ".cocod");
const mintUrl = "https://mint.example.com";
mkdirSync(walletDir, { recursive: true });
writeFileSync(
join(walletDir, "config.json"),
JSON.stringify({
version: 1,
mnemonic:
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
encrypted: false,
}),
);
// This fixture was generated with @routstr/cocod 0.0.24 using its
// coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents
// this test from accidentally creating its "legacy" database with the
// same current adapter that createCocoClient uses to read it.
const fixture = readFileSync(
join(import.meta.dir, "fixtures", "cocod-0.0.24-wallet.db.gz"),
);
writeFileSync(join(walletDir, "coco.db"), gunzipSync(fixture));
const client = await createCocoClient({ configDir: walletDir });
try {
expect(await client.getStatus()).toBe("UNLOCKED");
expect(await client.getBalances()).toEqual({ [mintUrl]: 10 });
} finally {
await client.dispose?.();
}
// Prove the migrated schema remains reopenable and the pre-existing
// proofs survive a complete in-process wallet restart.
const reopenedClient = await createCocoClient({ configDir: walletDir });
try {
expect(await reopenedClient.getBalances()).toEqual({ [mintUrl]: 10 });
} finally {
await reopenedClient.dispose?.();
}
});
});
describe("assertLegacyCocodNotRunning", () => {
it("does not probe when the legacy socket and PID file do not exist", async () => {
const fetchImpl = mock<LegacyFetch>(async () => new Response("pong"));
+29 -14
View File
@@ -64,13 +64,13 @@ interface CocodConfig {
encrypted: boolean;
}
function loadMnemonic(): string {
if (!existsSync(CONFIG_FILE)) {
function loadMnemonic(configFile: string = CONFIG_FILE): string {
if (!existsSync(configFile)) {
throw new Error(
`Config file not found at ${CONFIG_FILE}. Run 'routstrd onboard' first.`,
`Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
);
}
const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as CocodConfig;
const config = JSON.parse(readFileSync(configFile, "utf-8")) as CocodConfig;
if (config.encrypted) {
throw new Error(
"Encrypted wallets are not supported yet. Please use an unencrypted wallet.",
@@ -79,11 +79,6 @@ function loadMnemonic(): string {
return config.mnemonic;
}
async function seedGetter(): Promise<Uint8Array> {
const mnemonic = loadMnemonic();
return mnemonicToSeedSync(mnemonic);
}
function defaultIsProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -282,21 +277,41 @@ export function claimLegacyCocodPidFile(
};
}
export async function createCocoClient(): Promise<CocodClient> {
await assertLegacyCocodNotRunning();
const releaseLegacyPidClaim = claimLegacyCocodPidFile();
export interface CreateCocoClientOptions {
/** Override the wallet directory, primarily for migration tests and tooling. */
configDir?: string;
socketPath?: string;
pidFilePath?: string;
}
export async function createCocoClient(
options: CreateCocoClientOptions = {},
): Promise<CocodClient> {
const configDir = options.configDir || CONFIG_DIR;
const configFile = join(configDir, "config.json");
const dbPath = join(configDir, "coco.db");
const socketPath = options.socketPath ||
(options.configDir ? join(configDir, "cocod.sock") : LEGACY_COCOD_SOCKET);
const pidFilePath = options.pidFilePath ||
(options.configDir ? join(configDir, "cocod.pid") : LEGACY_COCOD_PID_FILE);
await assertLegacyCocodNotRunning({ socketPath, pidFilePath });
const releaseLegacyPidClaim = claimLegacyCocodPidFile({ pidFilePath });
let database: Database | undefined;
let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined;
try {
database = new Database(DB_PATH);
// Read and validate the existing cocod config during startup rather than
// deferring failure until coco-core first needs wallet key material.
const seed = mnemonicToSeedSync(loadMnemonic(configFile));
database = new Database(dbPath);
const repo = new SqliteRepositories({ database });
await repo.init();
coco = await initializeCoco({
repo,
seedGetter,
seedGetter: async () => seed,
});
} catch (error) {
database?.close();
Binary file not shown.