mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 23:24:03 -06:00
fix(zalouser): cancel abandoned setup QR sessions
This commit is contained in:
@@ -304,44 +304,90 @@ describe("zalouser setup wizard", () => {
|
||||
it.each([
|
||||
{ name: "first login", authenticated: false, expectedLogouts: 0 },
|
||||
{ name: "forced re-login", authenticated: true, expectedLogouts: 1 },
|
||||
])("reports $name QR startup failures", async ({ authenticated, expectedLogouts }) => {
|
||||
checkZaloAuthenticatedMock.mockResolvedValueOnce(authenticated);
|
||||
logoutZaloProfileMock.mockClear();
|
||||
startZaloQrLoginMock.mockClear();
|
||||
])(
|
||||
"cancels $name when QR startup remains pending",
|
||||
async ({ authenticated, expectedLogouts }) => {
|
||||
checkZaloAuthenticatedMock.mockResolvedValueOnce(authenticated);
|
||||
cancelZaloQrLoginMock.mockClear();
|
||||
logoutZaloProfileMock.mockClear();
|
||||
startZaloQrLoginMock.mockClear();
|
||||
startZaloQrLoginMock.mockResolvedValueOnce({
|
||||
message: "Still preparing QR. Call wait to continue checking login status.",
|
||||
});
|
||||
waitForZaloQrLoginMock.mockClear();
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createTestWizardPrompter({
|
||||
note,
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Zalo Personal already logged in. Keep session?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
await runSetup({ prompter });
|
||||
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"QR login stopped. Start login again to retry.",
|
||||
"QR Login",
|
||||
);
|
||||
expect(note).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Call wait"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(cancelZaloQrLoginMock).toHaveBeenCalledWith("default");
|
||||
expect(cancelZaloQrLoginMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
note.mock.invocationCallOrder.at(-1)!,
|
||||
);
|
||||
expect(logoutZaloProfileMock).toHaveBeenCalledTimes(expectedLogouts);
|
||||
expect(prompter.confirm).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Did you scan and approve the QR on your phone?" }),
|
||||
);
|
||||
if (expectedLogouts > 0) {
|
||||
expect(logoutZaloProfileMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
startZaloQrLoginMock.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
}
|
||||
expect(waitForZaloQrLoginMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels a disconnected QR wait before reporting retry guidance", async () => {
|
||||
checkZaloAuthenticatedMock.mockResolvedValueOnce(false);
|
||||
cancelZaloQrLoginMock.mockClear();
|
||||
startZaloQrLoginMock.mockResolvedValueOnce({
|
||||
message: "Failed to start QR login: invalid QR image",
|
||||
message: "Scan this QR with the Zalo app.",
|
||||
qrDataUrl: `data:image/png;base64,${PNG_1X1}`,
|
||||
});
|
||||
waitForZaloQrLoginMock.mockResolvedValueOnce({
|
||||
connected: false,
|
||||
message: "Still waiting for QR scan confirmation.",
|
||||
});
|
||||
waitForZaloQrLoginMock.mockClear();
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createTestWizardPrompter({
|
||||
note,
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Zalo Personal already logged in. Keep session?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
return (
|
||||
message === "Login via QR code now?" ||
|
||||
message === "Did you scan and approve the QR on your phone?"
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
await runSetup({ prompter });
|
||||
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Failed to start QR login: invalid QR image",
|
||||
"Login pending",
|
||||
expect(cancelZaloQrLoginMock).toHaveBeenCalledWith("default");
|
||||
expect(note).toHaveBeenLastCalledWith(
|
||||
"Still waiting for QR scan confirmation.\nQR login stopped. Start login again to retry.",
|
||||
"QR Login",
|
||||
);
|
||||
expect(logoutZaloProfileMock).toHaveBeenCalledTimes(expectedLogouts);
|
||||
expect(prompter.confirm).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Did you scan and approve the QR on your phone?" }),
|
||||
expect(cancelZaloQrLoginMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
note.mock.invocationCallOrder.at(-1)!,
|
||||
);
|
||||
if (expectedLogouts > 0) {
|
||||
expect(logoutZaloProfileMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
startZaloQrLoginMock.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
}
|
||||
expect(waitForZaloQrLoginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts DM policy before group access in quickstart", async () => {
|
||||
|
||||
@@ -292,7 +292,13 @@ async function runZalouserQrLogin(params: {
|
||||
: {}),
|
||||
});
|
||||
if (!start.qrDataUrl) {
|
||||
await params.prompter.note(start.message, t("wizard.zalouser.loginPendingTitle"));
|
||||
// Setup has no follow-up wait once it returns. Retire ownership before the
|
||||
// operator-facing note so a late vendor result cannot persist credentials.
|
||||
cancelZaloQrLogin(params.profile);
|
||||
await params.prompter.note(
|
||||
t("wizard.zalouser.qrLoginRetry"),
|
||||
t("wizard.zalouser.qrLoginTitle"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,9 +346,16 @@ async function runZalouserQrLogin(params: {
|
||||
profile: params.profile,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!waited.connected) {
|
||||
// A wait timeout deliberately leaves the generic QR session active. Setup
|
||||
// is terminal here, so release it before any awaited presentation work.
|
||||
cancelZaloQrLogin(params.profile);
|
||||
}
|
||||
await params.prompter.note(
|
||||
waited.message,
|
||||
waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"),
|
||||
waited.connected
|
||||
? waited.message
|
||||
: [waited.message, t("wizard.zalouser.qrLoginRetry")].join("\n"),
|
||||
waited.connected ? t("common.done") : t("wizard.zalouser.qrLoginTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,44 @@ describe("zalouser credential persistence", () => {
|
||||
expect(createZaloMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cancels the active vendor login before a late result can persist credentials", async () => {
|
||||
it("fences a late vendor completion after QR startup times out", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "qr-start-timeout-cancel";
|
||||
let resolveLogin: (api: API) => void = () => undefined;
|
||||
const loginResult = new Promise<API>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
});
|
||||
const api = createMockApi({
|
||||
imei: "late-start-imei",
|
||||
userAgent: "late-start-user-agent",
|
||||
cookies: [{ key: "zpsid", value: "late-start", domain: "chat.zalo.me" }],
|
||||
});
|
||||
|
||||
createZaloMock.mockResolvedValueOnce({
|
||||
loginQR: async () => await loginResult,
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const started = await startZaloQrLogin({ profile, timeoutMs: 3000 });
|
||||
expect(started).toEqual({
|
||||
message: "Still preparing QR. Call wait to continue checking login status.",
|
||||
});
|
||||
|
||||
cancelZaloQrLogin(profile);
|
||||
resolveLogin(api);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(loadStoredZaloCredentials(profile)).toBeNull();
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fences a late vendor completion after QR wait times out", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "qr-presentation-cancel";
|
||||
const abort = vi.fn();
|
||||
@@ -315,6 +352,11 @@ describe("zalouser credential persistence", () => {
|
||||
const started = await startZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
expect(started.qrDataUrl).toBe(`data:image/png;base64,${PNG_1X1}`);
|
||||
|
||||
await expect(waitForZaloQrLogin({ profile, timeoutMs: 1000 })).resolves.toEqual({
|
||||
connected: false,
|
||||
message: "Still waiting for QR scan confirmation.",
|
||||
});
|
||||
|
||||
cancelZaloQrLogin(profile);
|
||||
resolveLogin(api);
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -1004,6 +1004,7 @@ export const en = {
|
||||
peersLookupTip: "Tip: use `{command}` to look up people after onboarding.",
|
||||
qrImageSaved: "QR image saved to: {path}",
|
||||
qrImageWriteFailed: "Could not write QR image file; use gateway web login UI instead.",
|
||||
qrLoginRetry: "QR login stopped. Start login again to retry.",
|
||||
qrLoginTitle: "QR Login",
|
||||
qrScannedPrompt: "Did you scan and approve the QR on your phone?",
|
||||
scanApproveContinue: "Scan + approve on phone, then continue.",
|
||||
|
||||
@@ -972,6 +972,7 @@ export const zh_CN = {
|
||||
peersLookupTip: "提示:onboarding 后用 `{command}` 查找联系人。",
|
||||
qrImageSaved: "二维码图片已保存到:{path}",
|
||||
qrImageWriteFailed: "无法写入二维码图片文件;请改用 gateway web login UI。",
|
||||
qrLoginRetry: "二维码登录已停止。请重新开始登录以重试。",
|
||||
qrLoginTitle: "二维码登录",
|
||||
qrScannedPrompt: "你已经在手机上扫描并批准二维码了吗?",
|
||||
scanApproveContinue: "在手机上扫描并批准,然后继续。",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const zh_TW = {
|
||||
peersLookupTip: "提示:onboarding 後用 `{command}` 查找聯絡人。",
|
||||
qrImageSaved: "QR code 圖片已儲存到:{path}",
|
||||
qrImageWriteFailed: "無法寫入 QR code 圖片檔;請改用 gateway web login UI。",
|
||||
qrLoginRetry: "QR code 登入已停止。請重新開始登入以重試。",
|
||||
qrLoginTitle: "QR code 登入",
|
||||
qrScannedPrompt: "你已經在手機上掃描並核准 QR code 了嗎?",
|
||||
scanApproveContinue: "在手機上掃描並核准,然後繼續。",
|
||||
|
||||
Reference in New Issue
Block a user