From a2586b8b06651293a002485b08f48bc1f81e146d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 01:29:02 +0100 Subject: [PATCH] feat(web): add logout command and tests --- src/cli/program.ts | 19 ++++++++++++- src/provider-web.ts | 1 + src/web/login.test.ts | 6 ++--- src/web/logout.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/web/monitor-inbox.test.ts | 4 +-- src/web/outbound.test.ts | 4 +-- src/web/session.test.ts | 13 ++++----- src/web/session.ts | 15 +++++++++++ 8 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 src/web/logout.test.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index eea03f8a4..c38d23272 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,7 +5,12 @@ import { statusCommand } from "../commands/status.js"; import { webhookCommand } from "../commands/webhook.js"; import { ensureTwilioEnv } from "../env.js"; import { danger, info, setVerbose, setYes, warn } from "../globals.js"; -import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js"; +import { + loginWeb, + logoutWeb, + monitorWebProvider, + pickProvider, +} from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import type { Provider } from "../utils.js"; import { VERSION } from "../version.js"; @@ -104,6 +109,18 @@ export function buildProgram() { } }); + program + .command("logout") + .description("Clear cached WhatsApp Web credentials") + .action(async () => { + try { + await logoutWeb(defaultRuntime); + } catch (err) { + defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + }); + program .command("send") .description("Send a WhatsApp message") diff --git a/src/provider-web.ts b/src/provider-web.ts index 0fefaa37f..548dce40f 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -18,6 +18,7 @@ export { createWaSocket, formatError, getStatusCode, + logoutWeb, logWebSelfId, pickProvider, WA_WEB_AUTH_DIR, diff --git a/src/web/login.test.ts b/src/web/login.test.ts index 0ba80aa5b..d48d52e13 100644 --- a/src/web/login.test.ts +++ b/src/web/login.test.ts @@ -2,14 +2,14 @@ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; -import { loginWeb } from "./login.js"; -import type { waitForWaConnection } from "./session.js"; import { baileys, resetBaileysMocks, resetLoadConfigMock, } from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { loginWeb } from "./login.js"; +import type { waitForWaConnection } from "./session.js"; describe("web login", () => { beforeEach(() => { diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts new file mode 100644 index 000000000..d815716c9 --- /dev/null +++ b/src/web/logout.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +describe("web logout", () => { + const origHomedir = os.homedir; + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-logout-")); + vi.spyOn(os, "homedir").mockReturnValue(tmpDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fsPromises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + // restore for safety + // eslint-disable-next-line @typescript-eslint/unbound-method + (os.homedir as unknown as typeof origHomedir) = origHomedir; + }); + + it("deletes cached credentials when present", async () => { + const credsDir = path.join(tmpDir, ".warelay", "credentials"); + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); + + expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); + const result = await logoutWeb(runtime as never); + + expect(result).toBe(true); + expect(fs.existsSync(credsDir)).toBe(false); + }); + + it("no-ops when nothing to delete", async () => { + const { logoutWeb } = await import("./session.js"); + const result = await logoutWeb(runtime as never); + expect(result).toBe(false); + expect(runtime.log).toHaveBeenCalled(); + }); +}); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 64c230faa..c69f8a13c 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -5,13 +5,13 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; -import { monitorWebInbox } from "./inbound.js"; import { getLastSocket, resetBaileysMocks, resetLoadConfigMock, } from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { monitorWebInbox } from "./inbound.js"; describe("web monitor inbox", () => { beforeEach(() => { diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 1cf59d921..6b7e78aef 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; -import { sendMessageWeb } from "./outbound.js"; import { getLastSocket, resetBaileysMocks, resetLoadConfigMock, } from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { sendMessageWeb } from "./outbound.js"; describe("web outbound", () => { beforeEach(() => { diff --git a/src/web/session.test.ts b/src/web/session.test.ts index 0127d6eaf..e2f992243 100644 --- a/src/web/session.test.ts +++ b/src/web/session.test.ts @@ -3,18 +3,18 @@ import fsSync from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; -import { - createWaSocket, - logWebSelfId, - waitForWaConnection, -} from "./session.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock, } from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { + createWaSocket, + logWebSelfId, + waitForWaConnection, +} from "./session.js"; describe("web session", () => { beforeEach(() => { @@ -29,6 +29,7 @@ describe("web session", () => { vi.useRealTimers(); }); + it("creates WA socket with QR handler", async () => { await createWaSocket(true, false); const makeWASocket = baileys.makeWASocket as ReturnType; diff --git a/src/web/session.ts b/src/web/session.ts index 5732d98aa..ed9b30559 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -133,6 +133,21 @@ export async function webAuthExists() { .catch(() => false); } +export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { + const exists = await webAuthExists(); + if (!exists) { + runtime.log(info("No WhatsApp Web session found; nothing to delete.")); + return false; + } + await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); + runtime.log( + success( + "Cleared WhatsApp Web credentials. Run `warelay login --provider web` to relink.", + ), + ); + return true; +} + function readWebSelfId() { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");