mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
fix(whatsapp): require durable auth before login success (#92095)
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
// Whatsapp tests cover connection controller plugin behavior.
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DisconnectReason } from "baileys";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
||||
@@ -8,8 +11,9 @@ import {
|
||||
waitForWhatsAppLoginResult,
|
||||
WhatsAppConnectionController,
|
||||
} from "./connection-controller.js";
|
||||
import { enqueueCredsSave, writeCredsJsonAtomically } from "./creds-persistence.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
import { createWaSocket, readWebAuthExistsForDecision, waitForWaConnection } from "./session.js";
|
||||
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
@@ -18,11 +22,13 @@ vi.mock("./session.js", async () => {
|
||||
...actual,
|
||||
createWaSocket: vi.fn(),
|
||||
waitForWaConnection: vi.fn(),
|
||||
readWebAuthExistsForDecision: vi.fn(async () => ({ outcome: "stable" as const, exists: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
|
||||
|
||||
function createListenerStub(messageId = "ok") {
|
||||
return {
|
||||
@@ -47,6 +53,9 @@ describe("WhatsAppConnectionController", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
controller = new WhatsAppConnectionController({
|
||||
accountId: "work",
|
||||
authDir: "/tmp/wa-auth",
|
||||
@@ -249,6 +258,102 @@ describe("WhatsAppConnectionController", () => {
|
||||
expect(waitForConnection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns a retryable failure when the socket opens before auth persistence settles", async () => {
|
||||
readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "unstable" });
|
||||
const waitForConnection = vi.fn().mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: createSocketWithTransportEmitter() as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("failed");
|
||||
if (result.outcome === "failed") {
|
||||
expect(result.message).toMatch(/retry/i);
|
||||
expect((result.error as { code?: string })?.code).toBe("whatsapp-auth-unstable");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a retryable failure when auth is not linked on disk after the socket opens", async () => {
|
||||
readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: false });
|
||||
const waitForConnection = vi.fn().mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: createSocketWithTransportEmitter() as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("failed");
|
||||
if (result.outcome === "failed") {
|
||||
expect(result.message).toMatch(/retry/i);
|
||||
expect((result.error as { code?: string })?.code).toBe("whatsapp-auth-unstable");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns connected only after auth is confirmed durable on disk", async () => {
|
||||
readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
const waitForConnection = vi.fn().mockResolvedValueOnce(undefined);
|
||||
const sock = createSocketWithTransportEmitter();
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: sock as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: "connected", restarted: false, sock });
|
||||
expect(readWebAuthExistsForDecisionMock).toHaveBeenCalledWith("/tmp/wa-auth");
|
||||
});
|
||||
|
||||
it("waits for queued creds persistence so linked auth survives an auth-dir reuse", async () => {
|
||||
const actualSession = await vi.importActual<typeof import("./session.js")>("./session.js");
|
||||
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "wa-auth-durability-"));
|
||||
try {
|
||||
readWebAuthExistsForDecisionMock.mockImplementation(
|
||||
actualSession.readWebAuthExistsForDecision,
|
||||
);
|
||||
let credsSaved = false;
|
||||
enqueueCredsSave(
|
||||
authDir,
|
||||
async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
await writeCredsJsonAtomically(authDir, { me: { id: "123@s.whatsapp.net" } });
|
||||
credsSaved = true;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: createSocketWithTransportEmitter() as never,
|
||||
authDir,
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: vi.fn().mockResolvedValueOnce(undefined) as never,
|
||||
});
|
||||
|
||||
expect(credsSaved).toBe(true);
|
||||
expect(result.outcome).toBe("connected");
|
||||
// A fresh read of the same auth dir is what a restarted/rebuilt container does.
|
||||
await expect(actualSession.webAuthExists(authDir)).resolves.toBe(true);
|
||||
} finally {
|
||||
await fs.rm(authDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the previous registered controller until a replacement listener is ready", async () => {
|
||||
const liveController = new WhatsAppConnectionController({
|
||||
accountId: "work",
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
formatError,
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
readWebAuthExistsForDecision,
|
||||
waitForWaConnection,
|
||||
WhatsAppAuthUnstableError,
|
||||
} from "./session.js";
|
||||
import {
|
||||
DEFAULT_WHATSAPP_SOCKET_TIMING,
|
||||
@@ -30,6 +32,10 @@ const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
|
||||
"WhatsApp connection timed out before login; retrying with a fresh socket…";
|
||||
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
|
||||
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
|
||||
const WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE =
|
||||
"WhatsApp connected, but saving the linked credentials has not settled on disk yet. Retry login in a moment.";
|
||||
const WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE =
|
||||
"WhatsApp connected, but the linked credentials were not found on disk. Retry login in a moment.";
|
||||
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
|
||||
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
|
||||
export const WHATSAPP_WATCHDOG_TIMEOUT_ERROR = "watchdog-timeout";
|
||||
@@ -234,6 +240,22 @@ export async function waitForWhatsAppLoginResult(params: {
|
||||
while (true) {
|
||||
try {
|
||||
await wait(currentSock, { timeout: "none" });
|
||||
// Socket open only proves in-memory auth; require persisted creds before success.
|
||||
const persistedAuth = await readWebAuthExistsForDecision(params.authDir);
|
||||
if (persistedAuth.outcome === "unstable") {
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE,
|
||||
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE),
|
||||
};
|
||||
}
|
||||
if (!persistedAuth.exists) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE,
|
||||
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE),
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "connected",
|
||||
restarted: postPairingRestarted || timeoutRestarted,
|
||||
|
||||
@@ -114,6 +114,9 @@ describe("login-qr", () => {
|
||||
// Baileys v7 wraps the error: { error: BoomError(515) }
|
||||
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
@@ -242,6 +245,34 @@ describe("login-qr", () => {
|
||||
expect(createWaSocketMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report linked success when the socket opens before creds persistence stabilizes", async () => {
|
||||
const accountId = "socket-open-before-persistence";
|
||||
waitForWaConnectionMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(undefined), 20);
|
||||
}),
|
||||
);
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "unstable" });
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
|
||||
const result = await waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
currentQrDataUrl: start.qrDataUrl,
|
||||
accountId,
|
||||
});
|
||||
|
||||
expect(result.connected).toBe(false);
|
||||
expect(result.message).toMatch(/retry/i);
|
||||
});
|
||||
|
||||
it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => {
|
||||
createWaSocketMock.mockImplementationOnce(
|
||||
async (
|
||||
@@ -255,6 +286,9 @@ describe("login-qr", () => {
|
||||
);
|
||||
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
|
||||
readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null });
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
|
||||
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
|
||||
@@ -310,6 +344,9 @@ describe("login-qr", () => {
|
||||
setTimeout(() => resolve(undefined), 20);
|
||||
}),
|
||||
);
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
@@ -351,6 +388,9 @@ describe("login-qr", () => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
);
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
|
||||
@@ -59,6 +59,10 @@ vi.mock("./session.js", async () => {
|
||||
waitForWaConnection: waitForWaConnectionLocal,
|
||||
formatError: formatErrorLocal,
|
||||
getStatusCode,
|
||||
readWebAuthExistsForDecision: vi.fn(async () => ({
|
||||
outcome: "stable" as const,
|
||||
exists: true,
|
||||
})),
|
||||
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
|
||||
await fs.rm(params.authDir ?? authDir, {
|
||||
recursive: true,
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("./session.js", async () => {
|
||||
...actual,
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
readWebAuthExistsForDecision: vi.fn(async () => ({
|
||||
outcome: "stable" as const,
|
||||
exists: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user