fix(web): harden WhatsApp creds persistence

This commit is contained in:
Peter Steinberger
2025-12-21 13:58:31 +00:00
parent 4f3a3e93a9
commit ff63204d17
2 changed files with 149 additions and 3 deletions

View File

@@ -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();
});
});