fix(zalouser): cancel abandoned setup QR sessions

This commit is contained in:
jesse-merhi
2026-08-12 08:42:01 +10:00
parent 5194c3fafb
commit 5e84c19c82
6 changed files with 133 additions and 29 deletions
+71 -25
View File
@@ -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 () => {
+16 -3
View File
@@ -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) => {
+1
View File
@@ -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.",
+1
View File
@@ -972,6 +972,7 @@ export const zh_CN = {
peersLookupTip: "提示:onboarding 后用 `{command}` 查找联系人。",
qrImageSaved: "二维码图片已保存到:{path}",
qrImageWriteFailed: "无法写入二维码图片文件;请改用 gateway web login UI。",
qrLoginRetry: "二维码登录已停止。请重新开始登录以重试。",
qrLoginTitle: "二维码登录",
qrScannedPrompt: "你已经在手机上扫描并批准二维码了吗?",
scanApproveContinue: "在手机上扫描并批准,然后继续。",
+1
View File
@@ -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: "在手機上掃描並核准,然後繼續。",