diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts index e7031b6d4ac..4a5b3366c42 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts @@ -165,6 +165,45 @@ describe("credential lease runtime", () => { expect(chunkRequest.leaseToken).toBe("lease-chunked"); }); + it("validates chunked convex payload length as utf8 bytes", async () => { + const serialized = JSON.stringify({ + groupId: "-100123", + driverToken: "driv\u00e9r", + sutToken: "sut", + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + status: "ok", + credentialId: "cred-utf8", + leaseToken: "lease-utf8", + payload: { + __openclawQaCredentialPayloadChunksV1: true, + byteLength: Buffer.byteLength(serialized, "utf8"), + chunkCount: 1, + }, + }), + ) + .mockResolvedValueOnce(jsonResponse({ status: "ok", data: serialized })); + + const lease = await acquireQaCredentialLease({ + kind: "telegram", + source: "convex", + role: "ci", + env: { + OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site", + OPENCLAW_QA_CONVEX_SECRET_CI: "ci-secret", + }, + fetchImpl, + resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }), + parsePayload: (payload) => + payload as { groupId: string; driverToken: string; sutToken: string }, + }); + + expect(lease.payload.driverToken).toBe("driv\u00e9r"); + }); + it("defaults convex credential role to maintainer outside CI", async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( jsonResponse({ diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts index 95b7bbb7b3e..2cf168c44d0 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts @@ -328,7 +328,7 @@ async function resolveConvexCredentialPayload(params: { chunks.push(parsed.data); } const serialized = chunks.join(""); - if (serialized.length !== marker.byteLength) { + if (Buffer.byteLength(serialized, "utf8") !== marker.byteLength) { throw new Error("Chunked credential payload length mismatch."); } return JSON.parse(serialized) as unknown; diff --git a/scripts/e2e/telegram-user-credential.ts b/scripts/e2e/telegram-user-credential.ts index 813ae550711..9a66369c098 100644 --- a/scripts/e2e/telegram-user-credential.ts +++ b/scripts/e2e/telegram-user-credential.ts @@ -324,7 +324,7 @@ async function hydratePayloadFromLease(params: { const credentialId = requireString(params.acquired, "credentialId"); const leaseToken = requireString(params.acquired, "leaseToken"); const chunks: string[] = []; - let serializedLength = 0; + let serializedBytes = 0; for (let index = 0; index < marker.chunkCount; index += 1) { const chunk = await postBroker({ action: "payload-chunk", @@ -340,14 +340,14 @@ async function hydratePayloadFromLease(params: { }, }); const data = requireString(chunk, "data"); - serializedLength += data.length; - if (serializedLength > marker.byteLength) { + serializedBytes += Buffer.byteLength(data, "utf8"); + if (serializedBytes > marker.byteLength) { throw new Error("Chunked payload exceeded declared byteLength."); } chunks.push(data); } const serialized = chunks.join(""); - if (serializedLength !== marker.byteLength) { + if (serializedBytes !== marker.byteLength) { throw new Error("Chunked payload length mismatch."); } return parseTelegramUserQaCredentialPayload(JSON.parse(serialized)); @@ -640,4 +640,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); } -export { optionalPositiveInteger, parseChunkedPayloadMarker }; +export { hydratePayloadFromLease, optionalPositiveInteger, parseChunkedPayloadMarker }; diff --git a/test/scripts/telegram-user-credential.test.ts b/test/scripts/telegram-user-credential.test.ts index c9acebc41b7..1e70afdd1c5 100644 --- a/test/scripts/telegram-user-credential.test.ts +++ b/test/scripts/telegram-user-credential.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path, { win32 } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchJsonWithTimeout, runCommand } from "../../scripts/e2e/telegram-user-credential-io.ts"; import { expandHome, @@ -75,6 +75,8 @@ async function waitForExit( } afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { force: true, recursive: true }); } @@ -178,6 +180,64 @@ describe("telegram user credential IO", () => { ).toThrow("Chunked payload marker exceeds 67108864 bytes."); }); + it("hydrates chunked lease payloads using utf8 byte lengths", async () => { + const credentialModule = (await import( + `${new URL("../../scripts/e2e/telegram-user-credential.ts", import.meta.url).href}?case=utf8-chunk-${Date.now()}` + )) as { + hydratePayloadFromLease(params: { + acquired: Record; + ownerId: string; + siteUrl: string; + token: string; + }): Promise>; + }; + const sha256 = "a".repeat(64); + const serialized = JSON.stringify({ + groupId: "-100123", + sutToken: "sut-token", + testerUserId: "8709353529", + testerUsername: "OpenClawTestUser", + telegramApiId: "123456", + telegramApiHash: "api-hash-\u00e9", + tdlibDatabaseEncryptionKey: "db-key", + tdlibArchiveBase64: "tdlib-archive", + tdlibArchiveSha256: sha256, + desktopTdataArchiveBase64: "desktop-archive", + desktopTdataArchiveSha256: sha256, + }); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ status: "ok", data: serialized }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const payload = await credentialModule.hydratePayloadFromLease({ + acquired: { + credentialId: "cred-utf8", + leaseToken: "lease-utf8", + payload: { + [CHUNKED_PAYLOAD_MARKER]: true, + byteLength: Buffer.byteLength(serialized, "utf8"), + chunkCount: 1, + }, + }, + ownerId: "owner-utf8", + siteUrl: "https://qa.example.invalid", + token: "ci-secret", + }); + + expect(payload.telegramApiHash).toBe("api-hash-\u00e9"); + expect(fetchMock).toHaveBeenCalledWith( + "https://qa.example.invalid/qa-credentials/v1/payload-chunk", + expect.objectContaining({ + body: expect.stringContaining('"credentialId":"cred-utf8"'), + }), + ); + }); + it("rejects loose numeric credential limits instead of parsing prefixes", async () => { const credentialModule = (await import( `${new URL("../../scripts/e2e/telegram-user-credential.ts", import.meta.url).href}?case=limits-${Date.now()}`