fix(web): harden WhatsApp creds persistence
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ describe("web session", () => {
|
||||
).saveCreds;
|
||||
// trigger creds.update listener
|
||||
sock.ev.emit("creds.update", {});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(saveCreds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -107,4 +109,129 @@ describe("web session", () => {
|
||||
expect(formatError(err)).toContain("Request Time-out");
|
||||
expect(formatError(err)).toContain("QR refs attempts ended");
|
||||
});
|
||||
|
||||
it("does not clobber creds backup when creds.json is corrupted", async () => {
|
||||
const credsSuffix = path.join(".clawdis", "credentials", "creds.json");
|
||||
|
||||
const copySpy = vi
|
||||
.spyOn(fsSync, "copyFileSync")
|
||||
.mockImplementation(() => {});
|
||||
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
|
||||
if (typeof p !== "string") return false;
|
||||
return p.endsWith(credsSuffix);
|
||||
});
|
||||
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return { isFile: () => true, size: 12 } as never;
|
||||
}
|
||||
throw new Error(`unexpected statSync path: ${String(p)}`);
|
||||
});
|
||||
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return "{" as never;
|
||||
}
|
||||
throw new Error(`unexpected readFileSync path: ${String(p)}`);
|
||||
});
|
||||
|
||||
await createWaSocket(false, false);
|
||||
const sock = getLastSocket();
|
||||
const saveCreds = (
|
||||
await baileys.useMultiFileAuthState.mock.results[0].value
|
||||
).saveCreds;
|
||||
|
||||
sock.ev.emit("creds.update", {});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(copySpy).not.toHaveBeenCalled();
|
||||
expect(saveCreds).toHaveBeenCalled();
|
||||
|
||||
copySpy.mockRestore();
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("serializes creds.update saves to avoid overlapping writes", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
let release: (() => void) | null = null;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const saveCreds = vi.fn(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await gate;
|
||||
inFlight -= 1;
|
||||
});
|
||||
baileys.useMultiFileAuthState.mockResolvedValueOnce({
|
||||
state: { creds: {}, keys: {} },
|
||||
saveCreds,
|
||||
});
|
||||
|
||||
await createWaSocket(false, false);
|
||||
const sock = getLastSocket();
|
||||
|
||||
sock.ev.emit("creds.update", {});
|
||||
sock.ev.emit("creds.update", {});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(inFlight).toBe(1);
|
||||
|
||||
release?.();
|
||||
|
||||
// let both queued saves complete
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(saveCreds).toHaveBeenCalledTimes(2);
|
||||
expect(maxInFlight).toBe(1);
|
||||
expect(inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it("rotates creds backup when creds.json is valid JSON", async () => {
|
||||
const credsSuffix = path.join(".clawdis", "credentials", "creds.json");
|
||||
const backupSuffix = path.join(".clawdis", "credentials", "creds.json.bak");
|
||||
|
||||
const copySpy = vi
|
||||
.spyOn(fsSync, "copyFileSync")
|
||||
.mockImplementation(() => {});
|
||||
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
|
||||
if (typeof p !== "string") return false;
|
||||
return p.endsWith(credsSuffix);
|
||||
});
|
||||
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return { isFile: () => true, size: 12 } as never;
|
||||
}
|
||||
throw new Error(`unexpected statSync path: ${String(p)}`);
|
||||
});
|
||||
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
|
||||
if (typeof p === "string" && p.endsWith(credsSuffix)) {
|
||||
return "{}" as never;
|
||||
}
|
||||
throw new Error(`unexpected readFileSync path: ${String(p)}`);
|
||||
});
|
||||
|
||||
await createWaSocket(false, false);
|
||||
const sock = getLastSocket();
|
||||
const saveCreds = (
|
||||
await baileys.useMultiFileAuthState.mock.results[0].value
|
||||
).saveCreds;
|
||||
|
||||
sock.ev.emit("creds.update", {});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(copySpy).toHaveBeenCalledTimes(1);
|
||||
const args = copySpy.mock.calls[0] ?? [];
|
||||
expect(String(args[0] ?? "")).toContain(credsSuffix);
|
||||
expect(String(args[1] ?? "")).toContain(backupSuffix);
|
||||
expect(saveCreds).toHaveBeenCalled();
|
||||
|
||||
copySpy.mockRestore();
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,18 @@ export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
const WA_CREDS_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
||||
const WA_CREDS_BACKUP_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json.bak");
|
||||
|
||||
let credsSaveQueue: Promise<void> = Promise.resolve();
|
||||
function enqueueSaveCreds(
|
||||
saveCreds: () => Promise<void> | void,
|
||||
logger: ReturnType<typeof getChildLogger>,
|
||||
): void {
|
||||
credsSaveQueue = credsSaveQueue
|
||||
.then(() => safeSaveCreds(saveCreds, logger))
|
||||
.catch((err) => {
|
||||
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
|
||||
});
|
||||
}
|
||||
|
||||
function readCredsJsonRaw(filePath: string): string | null {
|
||||
try {
|
||||
if (!fsSync.existsSync(filePath)) return null;
|
||||
@@ -66,8 +78,15 @@ async function safeSaveCreds(
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Best-effort backup so we can recover after abrupt restarts.
|
||||
if (fsSync.existsSync(WA_CREDS_PATH)) {
|
||||
fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH);
|
||||
// Important: don't clobber a good backup with a corrupted/truncated creds.json.
|
||||
const raw = readCredsJsonRaw(WA_CREDS_PATH);
|
||||
if (raw) {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH);
|
||||
} catch {
|
||||
// keep existing backup
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore backup failures
|
||||
@@ -113,7 +132,7 @@ export async function createWaSocket(
|
||||
markOnlineOnConnect: false,
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", () => safeSaveCreds(saveCreds, sessionLogger));
|
||||
sock.ev.on("creds.update", () => enqueueSaveCreds(saveCreds, sessionLogger));
|
||||
sock.ev.on(
|
||||
"connection.update",
|
||||
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
||||
|
||||
Reference in New Issue
Block a user