From 5aebc5f23ac7fc2829ebf2d0a9329cf3f347362c Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:21:22 +1000 Subject: [PATCH] fix(zalouser): cancel declined QR login --- extensions/zalouser/src/setup-surface.test.ts | 27 ++++++++ extensions/zalouser/src/setup-surface.ts | 2 + .../zalouser/src/zalo-js.credentials.test.ts | 61 +++++++++++++++++++ extensions/zalouser/src/zalo-js.test-mocks.ts | 4 ++ extensions/zalouser/src/zalo-js.ts | 5 ++ 5 files changed, 99 insertions(+) diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 7222d486af26..98bc9845799e 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -11,6 +11,7 @@ import "./zalo-js.test-mocks.js"; import { zalouserSetupWizard } from "./setup-surface.js"; import { zalouserSetupPlugin } from "./setup-test-helpers.js"; import { + cancelZaloQrLoginMock, checkZaloAuthenticatedMock, logoutZaloProfileMock, resolveZaloAllowFromEntriesMock, @@ -20,6 +21,8 @@ import { } from "./zalo-js.test-mocks.js"; const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin); +const PNG_1X1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="; async function runSetup(params: { cfg?: OpenClawConfig; @@ -239,6 +242,30 @@ describe("zalouser setup wizard", () => { expect(waitForZaloQrLoginMock).not.toHaveBeenCalled(); }); + it("cancels the profile-owned QR login when confirmation is declined", async () => { + checkZaloAuthenticatedMock.mockResolvedValueOnce(false); + startZaloQrLoginMock.mockResolvedValueOnce({ + message: "Scan this QR with the Zalo app.", + qrDataUrl: `data:image/png;base64,${PNG_1X1}`, + }); + cancelZaloQrLoginMock.mockClear(); + waitForZaloQrLoginMock.mockClear(); + const prompter = createTestWizardPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return true; + } + return false; + }), + }); + + await runSetup({ prompter }); + + expect(cancelZaloQrLoginMock).toHaveBeenCalledOnce(); + expect(cancelZaloQrLoginMock).toHaveBeenCalledWith("default"); + expect(waitForZaloQrLoginMock).not.toHaveBeenCalled(); + }); + it.each([ { name: "first login", authenticated: false, expectedLogouts: 0 }, { name: "forced re-login", authenticated: true, expectedLogouts: 1 }, diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 970b811e16cf..5f10ad74cf7f 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -24,6 +24,7 @@ import { } from "./accounts.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { + cancelZaloQrLogin, logoutZaloProfile, resolveZaloAllowFromEntries, resolveZaloGroupsByEntries, @@ -315,6 +316,7 @@ async function runZalouserQrLogin(params: { initialValue: true, }); if (!scanned) { + cancelZaloQrLogin(params.profile); return; } diff --git a/extensions/zalouser/src/zalo-js.credentials.test.ts b/extensions/zalouser/src/zalo-js.credentials.test.ts index adcaee74266d..8d354848e96d 100644 --- a/extensions/zalouser/src/zalo-js.credentials.test.ts +++ b/extensions/zalouser/src/zalo-js.credentials.test.ts @@ -35,6 +35,7 @@ import { type StoredZaloCredentials, } from "./session-state.js"; import { + cancelZaloQrLogin, checkZaloAuthenticated, listZaloFriends, sendZaloLink, @@ -191,6 +192,66 @@ describe("zalouser credential persistence", () => { } }); + it("does not persist credentials after the profile-owned QR login is cancelled", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "qr-cancelled"; + const abort = vi.fn(); + let resolveLogin!: (api: API) => void; + const login = new Promise((resolve) => { + resolveLogin = resolve; + }); + let callback: ((event: LoginQRCallbackEvent) => unknown) | undefined; + const api = createMockApi({ + imei: "cancelled-imei", + userAgent: "cancelled-agent", + cookies: [{ key: "zpsid", value: "cancelled", domain: "chat.zalo.me" }], + }); + createZaloMock.mockResolvedValueOnce({ + loginQR: async ( + _options: unknown, + eventCallback?: (event: LoginQRCallbackEvent) => unknown, + ) => { + callback = eventCallback; + callback?.({ + type: LoginQRCallbackEventType.QRCodeGenerated, + data: { code: "qr-code", image: `data:image/png;base64,${PNG_1X1}` }, + actions: { + saveToFile: vi.fn(async () => undefined), + retry: vi.fn(), + abort, + }, + }); + return await login; + }, + }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const started = await startZaloQrLogin({ profile, timeoutMs: 1000 }); + expect(started.qrDataUrl).toBe(`data:image/png;base64,${PNG_1X1}`); + + cancelZaloQrLogin(profile); + callback?.({ + type: LoginQRCallbackEventType.GotLoginInfo, + data: { + cookie: [{ key: "zpsid", value: "late", domain: "chat.zalo.me" }], + imei: "late-imei", + userAgent: "late-agent", + }, + actions: null, + }); + resolveLogin(api); + await Promise.resolve(); + await Promise.resolve(); + + expect(abort).toHaveBeenCalledOnce(); + expect(loadStoredZaloCredentials(profile)).toBeNull(); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + it("rejects a non-PNG QR image and allows an immediate valid retry", async () => { const profile = "qr-invalid-image-retry"; const firstAbort = vi.fn(); diff --git a/extensions/zalouser/src/zalo-js.test-mocks.ts b/extensions/zalouser/src/zalo-js.test-mocks.ts index ea776b2c063a..091e53741f93 100644 --- a/extensions/zalouser/src/zalo-js.test-mocks.ts +++ b/extensions/zalouser/src/zalo-js.test-mocks.ts @@ -3,6 +3,7 @@ import { vi, type Mock } from "vitest"; type ZaloJsModule = typeof import("./zalo-js.js"); type ZaloJsMocks = { + cancelZaloQrLoginMock: Mock; checkZaloAuthenticatedMock: Mock; getZaloUserInfoMock: Mock; listZaloFriendsMock: Mock; @@ -23,6 +24,7 @@ type ZaloJsMocks = { const zaloJsMocks = vi.hoisted( (): ZaloJsMocks => ({ + cancelZaloQrLoginMock: vi.fn(), checkZaloAuthenticatedMock: vi.fn(async () => false), getZaloUserInfoMock: vi.fn(async () => null), listZaloFriendsMock: vi.fn(async () => []), @@ -66,6 +68,7 @@ const zaloJsMocks = vi.hoisted( ); export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock; +export const cancelZaloQrLoginMock = zaloJsMocks.cancelZaloQrLoginMock; export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock; export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock; export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock; @@ -79,6 +82,7 @@ export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock; export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock; vi.mock("./zalo-js.js", () => ({ + cancelZaloQrLogin: cancelZaloQrLoginMock, checkZaloAuthenticated: zaloJsMocks.checkZaloAuthenticatedMock, getZaloUserInfo: zaloJsMocks.getZaloUserInfoMock, listZaloFriends: listZaloFriendsMock, diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 46c46fd13c6c..61cb98266b91 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -757,6 +757,11 @@ function resetQrLogin(profileInput?: string | null): void { activeQrLogins.delete(profile); } +/** Stop one profile-owned QR attempt without clearing an existing authenticated session. */ +export function cancelZaloQrLogin(profileInput?: string | null): void { + resetQrLogin(profileInput); +} + async function fetchGroupsByIds(api: API, ids: string[]): Promise> { const result = new Map(); for (let index = 0; index < ids.length; index += GROUP_INFO_CHUNK_SIZE) {