Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const {
findConnectedPhoneNumberIdsMock,
findWabaMock,
getCoexistEligibilityMock,
getSharedWabaIdMock,
getSharedWabaIdsMock,
invalidateCacheByTagsMock,
isUniqueViolationErrorMock,
listPhoneNumbersMock,
Expand All @@ -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(),
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 41 additions & 4 deletions apps/builder/__tests__/whatsapp-reconnect-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const {
findWabaMock,
findWorkspaceIntegrationMock,
getCurrentUserAndTargetWorkspaceMock,
getSharedWabaIdMock,
getSharedWabaIdsMock,
hasWhatsappCapiScopeMock,
listPhoneNumbersMock,
platformCredentialResolveMock,
Expand All @@ -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(),
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -131,7 +131,7 @@ describe("reconnectWhatsappAction", () => {
},
},
})
getSharedWabaIdMock.mockResolvedValue("waba-1")
getSharedWabaIdsMock.mockResolvedValue(["waba-1"])
hasWhatsappCapiScopeMock.mockResolvedValue(true)
listPhoneNumbersMock.mockResolvedValue({
data: [
Expand Down Expand Up @@ -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"],
Expand Down
83 changes: 83 additions & 0 deletions apps/builder/__tests__/whatsapp-waba-grant.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from "@chatbotx.io/integration-whatsapp"
import {
exchangeAccessToken,
getSharedWabaId,
getSharedWabaIds,
} from "@chatbotx.io/integration-whatsapp/api/auth"
import {
getCoexistEligibility,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading