@@ -9,46 +9,120 @@ import { beforeEach, describe, expect, test, vi } from "vitest"
99// request host. Those legs run post-relay on the broker or branded host
1010// interchangeably, so a host-derived completion leg could silently pick a
1111// different OAuth app than the one the start leg authorized against,
12- // breaking the token exchange. This test pins that each completion leg
13- // forwards `parsedInput.workspaceId` into `resolvePlatformOwnerId`/
14- // `resolveOwnerForWorkspace` unchanged, rather than re-deriving it.
12+ // breaking the token exchange.
13+ //
14+ // Messenger's `connectMessengerPage` (plan §2.4/§4.7) gets its `workspaceId`
15+ // from the encrypted, httpOnly pending-auth cookie — never client input —
16+ // so this test pins that it forwards the COOKIE's workspaceId into
17+ // `resolvePlatformOwnerId`, and that a schema-invalid/missing cookie is
18+ // rejected with a `sessionError` before the resolver is ever called.
19+ // Instagram's two legs (phase 4) now go through the same
20+ // `resolveConnectSession` helper, so they get the identical treatment: the
21+ // wire payload carries only `igId`, never `workspaceId`.
1522// ---------------------------------------------------------------------------
1623
17- const { mockResolvePlatformOwnerId, mockResolveForOwner } = vi . hoisted ( ( ) => ( {
24+ const {
25+ mockResolvePlatformOwnerId,
26+ mockResolveForOwner,
27+ mockReadPendingAuth,
28+ mockWorkspaceFind,
29+ mockIsMember,
30+ } = vi . hoisted ( ( ) => ( {
1831 mockResolvePlatformOwnerId : vi . fn ( async ( ) => "resolved-owner-1" ) ,
1932 mockResolveForOwner : vi . fn ( async ( ) => undefined ) ,
33+ mockReadPendingAuth : vi . fn (
34+ async ( ) : Promise < {
35+ userToken : string
36+ workspaceId : string
37+ referer : string
38+ version : string
39+ expiresAt : number
40+ } | null > => ( {
41+ userToken : "user-token-1" ,
42+ workspaceId : "ws-1" ,
43+ referer : "/channels/create" ,
44+ version : "v23.0" ,
45+ expiresAt : Date . now ( ) + 600_000 ,
46+ } ) ,
47+ ) ,
48+ mockWorkspaceFind : vi . fn ( async ( ) => ( {
49+ id : "ws-1" ,
50+ ownerId : "owner-1" ,
51+ } ) ) ,
52+ mockIsMember : vi . fn ( async ( ) => true ) ,
2053} ) )
2154
2255// A passthrough action-client chain: `.inputSchema()`/`.action()` just
2356// return their handler so the test can call it directly with a hand-built
2457// `{ ctx, parsedInput }`, without instantiating the real safe-action /
2558// next-safe-action machinery. Mirrors the pattern in
2659// `instagram-facebook-settings-actions.test.ts`.
27- vi . mock ( "@/lib/safe-action" , ( ) => {
28- const chain : Record < string , unknown > = { }
29- chain . inputSchema = ( ) => chain
30- chain . action = ( handler : unknown ) => handler
31- return { authActionClient : chain }
32- } )
33-
3460vi . mock ( "@/lib/platform-credential-owner" , ( ) => ( {
3561 resolvePlatformOwnerId : mockResolvePlatformOwnerId ,
3662} ) )
3763
64+ // Bypassed entirely — this test is about credential-owner resolution, not
65+ // the trial/MAC gate (covered by `messenger-select-page-action.test.ts`).
66+ vi . mock ( "@/lib/workspace/authorize-workspace-access" , ( ) => ( {
67+ checkWorkspaceOwnerAccess : vi . fn ( async ( ) => null ) ,
68+ workspaceAccessDenialException : vi . fn (
69+ ( reason : string ) => new Error ( `denied:${ reason } ` ) ,
70+ ) ,
71+ } ) )
72+
3873vi . mock ( "@chatbotx.io/business" , ( ) => ( {
3974 platformCredentialService : { resolveForOwner : mockResolveForOwner } ,
40- workspaceService : { create : vi . fn ( ) } ,
41- resolveTenantSettings : vi . fn ( ) ,
75+ workspaceService : {
76+ create : vi . fn ( ) ,
77+ find : mockWorkspaceFind ,
78+ } ,
79+ workspaceMemberService : { isMember : mockIsMember } ,
80+ resolveTenantSettings : vi . fn ( async ( ) => ( { appUrl : "https://app.test" } ) ) ,
4281 updateInstagramIntegrationUserInfo : vi . fn ( ) ,
4382 updateMessengerIntegrationUserInfo : vi . fn ( ) ,
83+ messengerIntegrationService : {
84+ findConnectedPageIds : vi . fn ( async ( ) => new Set < string > ( ) ) ,
85+ connectPage : vi . fn ( ) ,
86+ updateUserInfo : vi . fn ( ) ,
87+ } ,
88+ instagramIntegrationService : {
89+ findConnectedIgIds : vi . fn ( async ( ) => new Set < string > ( ) ) ,
90+ connectAccount : vi . fn ( ) ,
91+ updateUserInfo : vi . fn ( ) ,
92+ } ,
4493 tagSyncService : { enqueueChannelScan : vi . fn ( ) } ,
4594 userQuotaService : { getAccessState : vi . fn ( async ( ) => ( { blocked : false } ) ) } ,
4695 connectChannelIntegration : vi . fn ( ) ,
96+ buildContext : vi . fn ( async ( ) => ( { } ) ) ,
4797} ) )
4898
49- vi . mock ( "@chatbotx.io/business/errors" , ( ) => ( {
50- ChatbotXException : class ChatbotXException extends Error { } ,
51- } ) )
99+ // The REAL session/item-outcome mapping table — `resolveConnectSession`
100+ // (called by `connectMessengerPage`) throws genuine exceptions from the
101+ // (also real, below) `@chatbotx.io/business/errors`, so this file lets the
102+ // real mapping classify them instead of re-implementing that table as a
103+ // second source of truth that could silently drift from production.
104+ vi . mock ( "@chatbotx.io/business/inbox/connect-outcome" , async ( importOriginal ) =>
105+ importOriginal ( ) ,
106+ )
107+
108+ vi . mock ( "@chatbotx.io/business/errors" , ( ) => {
109+ class ChatbotXException extends Error {
110+ code ?: string
111+ constructor ( message : string , code ?: string ) {
112+ super ( message )
113+ this . code = code
114+ }
115+ }
116+ return {
117+ ChatbotXException,
118+ connectSessionExpiredException : ( message : string ) =>
119+ new ChatbotXException ( message , "connectSessionExpired" ) ,
120+ notWorkspaceMemberException : ( ) =>
121+ new ChatbotXException ( "not a member" , "notWorkspaceMember" ) ,
122+ credentialMissingException : ( message : string ) =>
123+ new ChatbotXException ( message , "credentialMissing" ) ,
124+ }
125+ } )
52126
53127vi . mock ( "@chatbotx.io/database/client" , ( ) => ( {
54128 db : { transaction : vi . fn ( async ( ) => undefined ) } ,
@@ -67,6 +141,17 @@ vi.mock("@chatbotx.io/database/schema", async (importOriginal) => {
67141
68142vi . mock ( "@chatbotx.io/integration-messenger" , ( ) => ( {
69143 integration : { runChannelHandler : vi . fn ( ) } ,
144+ getUserPages : vi . fn ( async ( ) => ( {
145+ pages : [
146+ {
147+ id : "p1" ,
148+ name : "Page" ,
149+ access_token : "page-token" ,
150+ isConnectable : true ,
151+ } ,
152+ ] ,
153+ bmLookupFailed : false ,
154+ } ) ) ,
70155} ) )
71156vi . mock ( "@chatbotx.io/integration-messenger/apis/page" , ( ) => ( {
72157 exchangeLongLivedToken : vi . fn ( ) ,
@@ -107,7 +192,8 @@ vi.mock("@/lib/facebook-pending-auth", () => ({
107192 FB_MESSENGER_PENDING_AUTH_COOKIE : "fb_messenger_pending_auth" ,
108193 FB_INSTAGRAM_FACEBOOK_PENDING_AUTH_COOKIE :
109194 "fb_instagram_facebook_pending_auth" ,
110- readPendingAuth : vi . fn ( async ( ) => null ) ,
195+ FB_INSTAGRAM_PENDING_AUTH_COOKIE : "fb_instagram_pending_auth" ,
196+ readPendingAuth : mockReadPendingAuth ,
111197} ) )
112198vi . mock ( "@/lib/integration-user-info" , ( ) => ( {
113199 persistIntegrationUserInfo : vi . fn ( ) ,
@@ -116,23 +202,16 @@ vi.mock("@/lib/log", () => ({
116202 logger : { warn : vi . fn ( ) , error : vi . fn ( ) , info : vi . fn ( ) } ,
117203} ) )
118204
119- const { selectPageAction } = await import (
120- "../src/features/integration-messenger/actions/select -page.action "
205+ const { connectMessengerPage } = await import (
206+ "../src/features/integration-messenger/actions/connect -page"
121207)
122- const { selectAccountAction } = await import (
123- "../src/features/integration-instagram/actions/select -account.action "
208+ const { connectInstagramAccount } = await import (
209+ "../src/features/integration-instagram/actions/connect -account"
124210)
125- const { selectFacebookAccountAction } = await import (
126- "../src/features/integration-instagram/actions/select -account-facebook.action "
211+ const { connectInstagramAccountViaFacebook } = await import (
212+ "../src/features/integration-instagram/actions/connect -account-facebook"
127213)
128214
129- type ActionHandler = ( args : {
130- parsedInput : Record < string , unknown >
131- ctx : { user : { id : string } }
132- } ) => Promise < unknown >
133-
134- const call = ( action : unknown ) => action as ActionHandler
135-
136215describe ( "channel connect completion legs never re-derive the credential owner from the host" , ( ) => {
137216 beforeEach ( ( ) => {
138217 vi . clearAllMocks ( )
@@ -141,36 +220,43 @@ describe("channel connect completion legs never re-derive the credential owner f
141220 // resolver call — exactly the point this test needs to observe, without
142221 // running the rest of the (heavily mocked) connect transaction.
143222 mockResolveForOwner . mockResolvedValue ( undefined )
223+ mockReadPendingAuth . mockResolvedValue ( {
224+ userToken : "user-token-1" ,
225+ workspaceId : "ws-1" ,
226+ referer : "/channels/create" ,
227+ version : "v23.0" ,
228+ expiresAt : Date . now ( ) + 600_000 ,
229+ } )
230+ mockWorkspaceFind . mockResolvedValue ( { id : "ws-1" , ownerId : "owner-1" } )
231+ mockIsMember . mockResolvedValue ( true )
144232 } )
145233
146- test ( "select-page.action (messenger) forwards workspaceId unchanged" , async ( ) => {
147- await call ( selectPageAction ) ( {
148- parsedInput : { workspaceId : "ws-1" , pageId : "p1" , pageName : "Page" } ,
149- ctx : { user : { id : "user-1" } } ,
150- } ) . catch ( ( ) => undefined )
234+ test ( "connectMessengerPage resolves the credential owner from the pending-auth cookie's workspaceId" , async ( ) => {
235+ await connectMessengerPage ( { userId : "user-1" , pageId : "p1" } ) . catch (
236+ ( ) => undefined ,
237+ )
151238
152239 expect ( mockResolvePlatformOwnerId ) . toHaveBeenCalledWith ( {
153240 userId : "user-1" ,
154241 workspaceId : "ws-1" ,
155242 } )
156243 } )
157244
158- test ( "select-account.action (instagram) forwards workspaceId unchanged" , async ( ) => {
159- await call ( selectAccountAction ) ( {
160- parsedInput : { workspaceId : "ws-1" , igId : "ig1" , igName : "IG" } ,
161- ctx : { user : { id : "user-1" } } ,
162- } ) . catch ( ( ) => undefined )
245+ test ( "connectInstagramAccount resolves the credential owner from the pending-auth cookie's workspaceId" , async ( ) => {
246+ await connectInstagramAccount ( { userId : "user-1" , igId : "ig1" } ) . catch (
247+ ( ) => undefined ,
248+ )
163249
164250 expect ( mockResolvePlatformOwnerId ) . toHaveBeenCalledWith ( {
165251 userId : "user-1" ,
166252 workspaceId : "ws-1" ,
167253 } )
168254 } )
169255
170- test ( "select-account-facebook.action (instagram via Facebook) forwards workspaceId unchanged " , async ( ) => {
171- await call ( selectFacebookAccountAction ) ( {
172- parsedInput : { workspaceId : "ws -1", igId : "ig1" , igName : "IG" } ,
173- ctx : { user : { id : "user-1" } } ,
256+ test ( "connectInstagramAccountViaFacebook resolves the credential owner from the pending-auth cookie's workspaceId " , async ( ) => {
257+ await connectInstagramAccountViaFacebook ( {
258+ userId : "user -1",
259+ igId : "ig1" ,
174260 } ) . catch ( ( ) => undefined )
175261
176262 expect ( mockResolvePlatformOwnerId ) . toHaveBeenCalledWith ( {
@@ -179,15 +265,16 @@ describe("channel connect completion legs never re-derive the credential owner f
179265 } )
180266 } )
181267
182- test ( "no-workspace-yet connects (first channel ever) forward a nullish workspaceId, not a guessed one" , async ( ) => {
183- await call ( selectPageAction ) ( {
184- parsedInput : { workspaceId : undefined , pageId : "p1" , pageName : "Page" } ,
185- ctx : { user : { id : "user-1" } } ,
186- } ) . catch ( ( ) => undefined )
268+ test ( "connectMessengerPage never calls the resolver when the pending-auth cookie is missing/schema-invalid" , async ( ) => {
269+ mockReadPendingAuth . mockResolvedValue ( null )
187270
188- expect ( mockResolvePlatformOwnerId ) . toHaveBeenCalledWith ( {
271+ const result = await connectMessengerPage ( {
189272 userId : "user-1" ,
190- workspaceId : undefined ,
273+ pageId : "p1" ,
191274 } )
275+
276+ expect ( result ) . toEqual ( { kind : "sessionError" , code : "sessionExpired" } )
277+ expect ( mockResolvePlatformOwnerId ) . not . toHaveBeenCalled ( )
278+ expect ( mockWorkspaceFind ) . not . toHaveBeenCalled ( )
192279 } )
193280} )
0 commit comments