fix(zalouser): cancel declined QR login

This commit is contained in:
jesse-merhi
2026-08-12 01:21:22 +10:00
parent c6f3ec3eac
commit 5aebc5f23a
5 changed files with 99 additions and 0 deletions
@@ -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 },
+2
View File
@@ -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;
}
@@ -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<API>((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();
@@ -3,6 +3,7 @@ import { vi, type Mock } from "vitest";
type ZaloJsModule = typeof import("./zalo-js.js");
type ZaloJsMocks = {
cancelZaloQrLoginMock: Mock<ZaloJsModule["cancelZaloQrLogin"]>;
checkZaloAuthenticatedMock: Mock<ZaloJsModule["checkZaloAuthenticated"]>;
getZaloUserInfoMock: Mock<ZaloJsModule["getZaloUserInfo"]>;
listZaloFriendsMock: Mock<ZaloJsModule["listZaloFriends"]>;
@@ -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,
+5
View File
@@ -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<Map<string, GroupInfo>> {
const result = new Map<string, GroupInfo>();
for (let index = 0; index < ids.length; index += GROUP_INFO_CHUNK_SIZE) {