diff --git a/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts b/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts index b9ddcff0b5..6887b0bd58 100644 --- a/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts +++ b/apps/builder/__tests__/whatsapp-connect-action-registration.test.ts @@ -34,7 +34,7 @@ const { findConnectedPhoneNumberIdsMock, findWabaMock, getCoexistEligibilityMock, - getSharedWabaIdMock, + getSharedWabaIdsMock, invalidateCacheByTagsMock, isUniqueViolationErrorMock, listPhoneNumbersMock, @@ -60,7 +60,7 @@ const { findConnectedPhoneNumberIdsMock: vi.fn(), findWabaMock: vi.fn(), getCoexistEligibilityMock: vi.fn(), - getSharedWabaIdMock: vi.fn(), + getSharedWabaIdsMock: vi.fn(), invalidateCacheByTagsMock: vi.fn(), isUniqueViolationErrorMock: vi.fn(), listPhoneNumbersMock: vi.fn(), @@ -152,7 +152,7 @@ vi.mock("@chatbotx.io/integration-whatsapp", () => ({ vi.mock("@chatbotx.io/integration-whatsapp/api/auth", () => ({ debugToken: vi.fn(), exchangeAccessToken: exchangeAccessTokenMock, - getSharedWabaId: getSharedWabaIdMock, + getSharedWabaIds: getSharedWabaIdsMock, })) vi.mock("@chatbotx.io/integration-whatsapp/api/phone-number", () => ({ @@ -293,7 +293,7 @@ describe("connectWhatsappAction registration", () => { exchangeAccessTokenMock.mockResolvedValue({ access_token: "access-token-1", }) - getSharedWabaIdMock.mockResolvedValue("waba-1") + getSharedWabaIdsMock.mockResolvedValue(["waba-1"]) findWabaMock.mockResolvedValue({ id: "waba-1", owner_business_info: { id: "business-1" }, @@ -456,6 +456,62 @@ describe("connectWhatsappAction registration", () => { expect(registerPhoneNumberMock).not.toHaveBeenCalled() }) + test("keeps the client-selected WABA when the token lists it after other WABAs", async () => { + // A system-user token grants every WABA shared with the app, in no stable + // order; the selected one must win by membership, not by position. + getSharedWabaIdsMock.mockResolvedValue(["waba-9", "waba-1"]) + + await callConnectWhatsappAction({ + ctx: { user: { id: "user-1" } }, + parsedInput: { + businessId: null, + wabaId: "waba-1", + connectExisting: false, + transferPhoneNumber: false, + manualConnect: false, + marketingMessageLite: true, + phoneNumberId: null, + workspaceId: "ws-1", + signupSessionId: null, + accessToken: null, + code: "oauth-code-1", + }, + }) + + expect(findWabaMock).toHaveBeenCalledWith( + expect.objectContaining({ wabaId: "waba-1" }), + ) + expect(listPhoneNumbersMock).toHaveBeenCalledWith( + expect.objectContaining({ wabaId: "waba-1" }), + ) + }) + + test("rejects a client-selected WABA the token does not grant", async () => { + getSharedWabaIdsMock.mockResolvedValue(["waba-9"]) + + await expect( + callConnectWhatsappAction({ + ctx: { user: { id: "user-1" } }, + parsedInput: { + businessId: null, + wabaId: "waba-1", + connectExisting: false, + transferPhoneNumber: false, + manualConnect: false, + marketingMessageLite: true, + phoneNumberId: null, + workspaceId: "ws-1", + signupSessionId: null, + accessToken: null, + code: "oauth-code-1", + }, + }), + ).rejects.toThrow( + "Selected WhatsApp Business Account does not match the authorization.", + ) + expect(findWabaMock).not.toHaveBeenCalled() + }) + test("returns phone verification result when registration requires OTP", async () => { const registrationError = { code: 100, diff --git a/apps/builder/__tests__/whatsapp-reconnect-action.test.ts b/apps/builder/__tests__/whatsapp-reconnect-action.test.ts index 9ba425c510..9a699e52de 100644 --- a/apps/builder/__tests__/whatsapp-reconnect-action.test.ts +++ b/apps/builder/__tests__/whatsapp-reconnect-action.test.ts @@ -18,7 +18,7 @@ const { findWabaMock, findWorkspaceIntegrationMock, getCurrentUserAndTargetWorkspaceMock, - getSharedWabaIdMock, + getSharedWabaIdsMock, hasWhatsappCapiScopeMock, listPhoneNumbersMock, platformCredentialResolveMock, @@ -29,7 +29,7 @@ const { findWabaMock: vi.fn(), findWorkspaceIntegrationMock: vi.fn(), getCurrentUserAndTargetWorkspaceMock: vi.fn(), - getSharedWabaIdMock: vi.fn(), + getSharedWabaIdsMock: vi.fn(), hasWhatsappCapiScopeMock: vi.fn(), listPhoneNumbersMock: vi.fn(), platformCredentialResolveMock: vi.fn(), @@ -79,7 +79,7 @@ vi.mock("@chatbotx.io/business/errors", () => ({ vi.mock("@chatbotx.io/integration-whatsapp/api/auth", () => ({ exchangeAccessToken: exchangeAccessTokenMock, - getSharedWabaId: getSharedWabaIdMock, + getSharedWabaIds: getSharedWabaIdsMock, })) vi.mock("@chatbotx.io/integration-whatsapp/api/phone-number", () => ({ @@ -131,7 +131,7 @@ describe("reconnectWhatsappAction", () => { }, }, }) - getSharedWabaIdMock.mockResolvedValue("waba-1") + getSharedWabaIdsMock.mockResolvedValue(["waba-1"]) hasWhatsappCapiScopeMock.mockResolvedValue(true) listPhoneNumbersMock.mockResolvedValue({ data: [ @@ -184,6 +184,43 @@ describe("reconnectWhatsappAction", () => { expect(subscribeWebhookMock).not.toHaveBeenCalled() }) + test("accepts the reconnect when the existing WABA is not first in the token's target list", async () => { + // Meta returns every WABA shared with the app and does not keep the order + // stable between tokens, so matching on the first entry made reconnect + // fail at random for businesses with several WABAs. + getSharedWabaIdsMock.mockResolvedValue(["waba-9", "waba-1", "waba-5"]) + + await callReconnectWhatsappAction({ + bindArgsParsedInputs: ["ws-1", "iw-1"], + ctx: { workspace: { id: "ws-1", ownerId: "owner-1" } }, + parsedInput: { code: "oauth-code" }, + }) + + expect(replaceAuthMock).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + metadata: expect.objectContaining({ wabaId: "waba-1" }), + }), + }), + ) + expect(findWabaMock).toHaveBeenCalledWith( + expect.objectContaining({ wabaId: "waba-1" }), + ) + }) + + test("rejects the reconnect when the token does not cover the existing WABA", async () => { + getSharedWabaIdsMock.mockResolvedValue(["waba-9", "waba-5"]) + + await expect( + callReconnectWhatsappAction({ + bindArgsParsedInputs: ["ws-1", "iw-1"], + ctx: { workspace: { id: "ws-1", ownerId: "owner-1" } }, + parsedInput: { code: "oauth-code" }, + }), + ).rejects.toThrow("whatsapp.reconnect.errors.wabaMismatch") + expect(replaceAuthMock).not.toHaveBeenCalled() + }) + test("resubscribes with automatic_events during ads reconnect", async () => { await callReconnectWhatsappAction({ bindArgsParsedInputs: ["ws-1", "iw-1"], diff --git a/apps/builder/__tests__/whatsapp-waba-grant.test.ts b/apps/builder/__tests__/whatsapp-waba-grant.test.ts new file mode 100644 index 0000000000..6664a5d00d --- /dev/null +++ b/apps/builder/__tests__/whatsapp-waba-grant.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node + +import { describe, expect, test } from "vitest" +import { + requireGrantedWabaId, + resolveGrantedWabaId, + WABA_GRANT_FAILURES, +} from "../src/features/integration-whatsapp/libs/waba-grant" + +const errorMessages = { + none: "no waba granted", + mismatch: "waba mismatch", +} + +describe("resolveGrantedWabaId", () => { + test("uses the requested WABA when the token grants it, regardless of position", () => { + expect( + resolveGrantedWabaId({ + grantedWabaIds: ["waba-9", "waba-1", "waba-5"], + requestedWabaId: "waba-1", + }), + ).toEqual({ wabaId: "waba-1" }) + }) + + test("falls back to the first grant only when nothing was requested", () => { + expect( + resolveGrantedWabaId({ grantedWabaIds: ["waba-9", "waba-1"] }), + ).toEqual({ wabaId: "waba-9" }) + expect( + resolveGrantedWabaId({ + grantedWabaIds: ["waba-9", "waba-1"], + requestedWabaId: null, + }), + ).toEqual({ wabaId: "waba-9" }) + }) + + test("reports a mismatch when the requested WABA is not granted", () => { + expect( + resolveGrantedWabaId({ + grantedWabaIds: ["waba-9"], + requestedWabaId: "waba-1", + }), + ).toEqual({ failure: WABA_GRANT_FAILURES.MISMATCH }) + }) + + test("reports no grant when the token carries no WABA, even if one was requested", () => { + expect( + resolveGrantedWabaId({ grantedWabaIds: [], requestedWabaId: "waba-1" }), + ).toEqual({ failure: WABA_GRANT_FAILURES.NONE }) + expect(resolveGrantedWabaId({ grantedWabaIds: [] })).toEqual({ + failure: WABA_GRANT_FAILURES.NONE, + }) + }) +}) + +describe("requireGrantedWabaId", () => { + test("returns the resolved WABA id", () => { + expect( + requireGrantedWabaId({ + grantedWabaIds: ["waba-9", "waba-1"], + requestedWabaId: "waba-1", + errorMessages, + }), + ).toBe("waba-1") + }) + + test("throws the caller's message for each failure", () => { + expect(() => + requireGrantedWabaId({ + grantedWabaIds: [], + requestedWabaId: "waba-1", + errorMessages, + }), + ).toThrow(errorMessages.none) + expect(() => + requireGrantedWabaId({ + grantedWabaIds: ["waba-9"], + requestedWabaId: "waba-1", + errorMessages, + }), + ).toThrow(errorMessages.mismatch) + }) +}) diff --git a/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts b/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts index 9ae8437bd9..9106cf4991 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/connect.action.ts @@ -33,7 +33,7 @@ import { } from "@chatbotx.io/integration-whatsapp" import { exchangeAccessToken, - getSharedWabaId, + getSharedWabaIds, } from "@chatbotx.io/integration-whatsapp/api/auth" import { getCoexistEligibility, @@ -58,6 +58,7 @@ import { WHATSAPP_OAUTH_CALLBACK_PATH, } from "../libs/embedded-signup" import { toRegistrationOutcome } from "../libs/registration-outcome" +import { requireGrantedWabaId } from "../libs/waba-grant" import { CONNECT_WHATSAPP_RESULT_TYPES, type ConnectWhatsappResult, @@ -102,14 +103,19 @@ async function deriveSignupTargets( appAccessToken: string, version: string, messages: ConnectErrorMessages, + requestedWabaId?: string | null, ): Promise<{ wabaId: string businessId: string }> { - const wabaId = await getSharedWabaId(accessToken, appAccessToken) - if (!wabaId) { - throw new ChatbotXException(messages.wabaResolveFailed) - } + const wabaId = requireGrantedWabaId({ + grantedWabaIds: await getSharedWabaIds(accessToken, appAccessToken), + requestedWabaId, + errorMessages: { + none: messages.wabaResolveFailed, + mismatch: messages.wabaMismatch, + }, + }) const waba = await findWaba({ wabaId, @@ -443,12 +449,9 @@ async function prepareConnectInput(params: { `${whatsappSettings.clientId}|${whatsappSettings.clientSecret}`, whatsappSettings.version, messages, + input.wabaId, ) - if (input.wabaId && input.wabaId !== targets.wabaId) { - throw new ChatbotXException(messages.wabaMismatch) - } - if (input.phoneNumberId) { return { source: "direct", diff --git a/apps/builder/src/features/integration-whatsapp/actions/reconnect.action.ts b/apps/builder/src/features/integration-whatsapp/actions/reconnect.action.ts index 8ebaa99305..95e5c3a549 100644 --- a/apps/builder/src/features/integration-whatsapp/actions/reconnect.action.ts +++ b/apps/builder/src/features/integration-whatsapp/actions/reconnect.action.ts @@ -10,7 +10,7 @@ import type { WhatsappCredential } from "@chatbotx.io/database/partials" import type { WorkspaceModel } from "@chatbotx.io/database/types" import { exchangeAccessToken, - getSharedWabaId, + getSharedWabaIds, } from "@chatbotx.io/integration-whatsapp/api/auth" import { listPhoneNumbers as whatsappListPhoneNumbers } from "@chatbotx.io/integration-whatsapp/api/phone-number" import { findWaba } from "@chatbotx.io/integration-whatsapp/api/waba" @@ -24,6 +24,7 @@ import { logger } from "@/lib/log" import { resolveProviderOriginForCredential } from "@/lib/provider-origin" import { workspaceActionClient } from "@/lib/safe-action" import { WHATSAPP_OAUTH_CALLBACK_PATH } from "../libs/embedded-signup" +import { requireGrantedWabaId } from "../libs/waba-grant" import { buildAuthValue, buildWebhookConfig } from "./webhook-url" const reconnectWhatsappSchema = z.object({ @@ -89,17 +90,14 @@ async function exchangeAndValidateWhatsappAccount(input: { ) ).access_token const appAccessToken = `${input.whatsappSettings.clientId}|${input.whatsappSettings.clientSecret}` - const wabaId = await getSharedWabaId(accessToken, appAccessToken) - if (!wabaId) { - throw new ChatbotXException( - input.t("whatsapp.connect.errors.wabaResolveFailed"), - ) - } - if (wabaId !== input.existing.wabaId) { - throw new ChatbotXException( - input.t("whatsapp.reconnect.errors.wabaMismatch"), - ) - } + const wabaId = requireGrantedWabaId({ + grantedWabaIds: await getSharedWabaIds(accessToken, appAccessToken), + requestedWabaId: input.existing.wabaId, + errorMessages: { + none: input.t("whatsapp.connect.errors.wabaResolveFailed"), + mismatch: input.t("whatsapp.reconnect.errors.wabaMismatch"), + }, + }) const [waba, phoneNumbers] = await Promise.all([ findWaba({ diff --git a/apps/builder/src/features/integration-whatsapp/libs/waba-grant.ts b/apps/builder/src/features/integration-whatsapp/libs/waba-grant.ts new file mode 100644 index 0000000000..64e7f0ff36 --- /dev/null +++ b/apps/builder/src/features/integration-whatsapp/libs/waba-grant.ts @@ -0,0 +1,59 @@ +import { ChatbotXException } from "@chatbotx.io/business/errors" + +/** + * Why a token's `whatsapp_business_management` grant could not be matched to + * the WABA a caller expects. Keys double as the lookup into the caller's + * translated error messages. + */ +export const WABA_GRANT_FAILURES = { + /** The token was granted no WABA at all. */ + NONE: "none", + /** The token has WABAs, but not the one the caller asked for. */ + MISMATCH: "mismatch", +} as const + +export type WabaGrantFailure = + (typeof WABA_GRANT_FAILURES)[keyof typeof WABA_GRANT_FAILURES] + +export type WabaGrantErrorMessages = Record + +export type WabaGrantResolution = + | { wabaId: string; failure?: never } + | { wabaId?: never; failure: WabaGrantFailure } + +/** + * Pick the WABA an embedded-signup token is for. A system-user token lists + * every WABA the business has shared with the app, in no stable order, so a + * caller that already knows its WABA (reconnect, or a connect whose client sent + * `wabaId`) must match by membership — never by position. Only a fresh signup + * with no hint falls back to the first grant. + */ +export function resolveGrantedWabaId(params: { + grantedWabaIds: readonly string[] + requestedWabaId?: string | null +}): WabaGrantResolution { + const [firstGrantedWabaId] = params.grantedWabaIds + if (!firstGrantedWabaId) { + return { failure: WABA_GRANT_FAILURES.NONE } + } + if (!params.requestedWabaId) { + return { wabaId: firstGrantedWabaId } + } + if (!params.grantedWabaIds.includes(params.requestedWabaId)) { + return { failure: WABA_GRANT_FAILURES.MISMATCH } + } + return { wabaId: params.requestedWabaId } +} + +/** `resolveGrantedWabaId`, raising the caller's translated message on failure. */ +export function requireGrantedWabaId(params: { + grantedWabaIds: readonly string[] + requestedWabaId?: string | null + errorMessages: WabaGrantErrorMessages +}): string { + const resolution = resolveGrantedWabaId(params) + if (resolution.failure) { + throw new ChatbotXException(params.errorMessages[resolution.failure]) + } + return resolution.wabaId +} diff --git a/integrations/whatsapp/__tests__/auth.test.ts b/integrations/whatsapp/__tests__/auth.test.ts index b28ef087b0..b1e51c7a41 100644 --- a/integrations/whatsapp/__tests__/auth.test.ts +++ b/integrations/whatsapp/__tests__/auth.test.ts @@ -11,7 +11,7 @@ vi.mock("ky", async () => { } }) -import { debugToken, getSharedWabaId } from "../src/api/auth" +import { debugToken, getSharedWabaIds } from "../src/api/auth" type KyGetOptions = { searchParams: { @@ -45,7 +45,7 @@ describe("debugToken", () => { }) }) - it("resolves the WABA id from a token debugged by the app token", async () => { + it("resolves the granted WABA ids from a token debugged by the app token", async () => { getMock.mockReturnValueOnce( okResponse({ data: { @@ -62,13 +62,47 @@ describe("debugToken", () => { }), ) - const wabaId = await getSharedWabaId("user-token", "app-id|app-secret") + const wabaIds = await getSharedWabaIds("user-token", "app-id|app-secret") const [, options] = getMock.mock.calls[0] as [string, KyGetOptions] - expect(wabaId).toBe("waba-1") + expect(wabaIds).toEqual(["waba-1"]) expect(options.searchParams).toMatchObject({ input_token: "user-token", access_token: "app-id|app-secret", }) }) + + it("returns every WABA the token was granted, in Meta's order", async () => { + // A system-user token carries every WABA the business has shared with the + // app, not just the one picked in the dialog — and Meta does not promise + // a stable order between tokens. + getMock.mockReturnValueOnce( + okResponse({ + data: { + app_id: "app-1", + is_valid: true, + granular_scopes: [ + { + scope: "whatsapp_business_management", + target_ids: ["waba-9", "waba-1", "waba-5"], + }, + ], + }, + }), + ) + + await expect( + getSharedWabaIds("user-token", "app-id|app-secret"), + ).resolves.toEqual(["waba-9", "waba-1", "waba-5"]) + }) + + it("returns an empty list when the token has no management scope", async () => { + getMock.mockReturnValueOnce( + okResponse({ data: { app_id: "app-1", is_valid: true } }), + ) + + await expect( + getSharedWabaIds("user-token", "app-id|app-secret"), + ).resolves.toEqual([]) + }) }) diff --git a/integrations/whatsapp/src/api/auth.ts b/integrations/whatsapp/src/api/auth.ts index 57ef4805b9..b0b80140e8 100644 --- a/integrations/whatsapp/src/api/auth.ts +++ b/integrations/whatsapp/src/api/auth.ts @@ -118,19 +118,20 @@ export function debugTokenOrThrow( } /** - * Resolve the WhatsApp Business Account id granted to the access token. Embedded - * signup grants exactly one WABA via the `whatsapp_business_management` scope, so - * its `target_ids[0]` is the connected WABA. Used to reconstruct the connect - * inputs server-side when the OAuth dialog returns only a `code` (the SDK-only - * `WA_EMBEDDED_SIGNUP` postMessage that normally carries the ids never fires). + * Resolve every WhatsApp Business Account id the access token was granted via + * the `whatsapp_business_management` scope. A system-user token from embedded + * signup carries every WABA the business has already shared with the app — not + * only the one picked in the dialog — and Meta does not keep the order stable + * between tokens. Callers that already know which WABA they expect must check + * membership in this list instead of trusting the first entry. */ -export async function getSharedWabaId( +export async function getSharedWabaIds( accessToken: string, appAccessToken: string, -): Promise { +): Promise { const data = await debugToken(accessToken, appAccessToken) const scope = data?.granular_scopes?.find( (s) => s.scope === WHATSAPP_BUSINESS_MANAGEMENT_SCOPE, ) - return scope?.target_ids?.[0] ?? null + return scope?.target_ids ?? [] }