diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts new file mode 100644 index 00000000000..bc5d24885c6 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.test.ts @@ -0,0 +1,28 @@ +import { describe,expect,it } from "vitest"; +import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js"; +import { assertSlackCall,assertSlackFixture,slackIngressMatches,type SlackIngressReceipt } from "./external-slack-provider.js"; +function binding(){return {disposable:true as const,resourceIds:["TWORKSPACE","CCHANNEL","USENDER","UBOT","AAPP"],credentialEnv:["PAPERCLIP_CHAT_EVAL_SLACK_BOT","PAPERCLIP_CHAT_EVAL_SLACK_SENDER","PAPERCLIP_CHAT_EVAL_SLACK_SIGNING","PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"],prerequisites:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",senderUserId:"USENDER",botUserId:"UBOT",appId:"AAPP",registeredWebhookOrigin:"https://isolated.example.test",botTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_BOT",senderTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SENDER",signingSecretEnv:"PAPERCLIP_CHAT_EVAL_SLACK_SIGNING",configurationTokenEnv:"PAPERCLIP_CHAT_EVAL_SLACK_CONFIG"}};} +function ingress(){const receipt:SlackIngressReceipt={providerCalls:1,query:"Exact canonical query.",endpointId:"endpoint",conversationId:"conversation",issueId:"issue",humanCommentId:"comment",linkedPaperclipUserId:"human",provider:{workspaceId:"TWORKSPACE",channelId:"CCHANNEL",threadId:"1793050000.123456",canonicalMessageId:"1793050001.123456",senderUserId:"USENDER"}};return {receipt,endpoint:{id:"endpoint",companyId:"company",assignedAgentId:"ceo",provider:"slack",providerAccountId:"TWORKSPACE",botExternalId:"UBOT"} as ChatEndpoint,conversation:{id:"conversation",endpointId:"endpoint",companyId:"company",issueId:"issue",externalConversationId:"slack:CCHANNEL",externalThreadId:"slack:CCHANNEL:1793050000.123456"} as ChatConversation,binding:{endpointId:"endpoint",conversationId:"conversation",provider:"slack",assignedAgentLocked:true} as ExternalChannelBindingSummary,comment:{id:"comment",body:"Exact canonical query.",authorUserId:"human",metadata:{sourceChannel:"slack"}},canonicalQuery:"Exact canonical query.",companyId:"company",agentId:"ceo"};} +describe("finite real Slack companion prerequisites and source binding",()=>{ + it("requires each explicit disposable provider resource and a distinct actual human sender",()=>{ + expect(()=>assertSlackFixture(binding())).not.toThrow();const other=binding();other.resourceIds=other.resourceIds.filter(id=>id!=="USENDER");expect(()=>assertSlackFixture(other)).toThrow(/allowlisted/); + const bot=binding();bot.prerequisites.senderUserId="UBOT";expect(()=>assertSlackFixture(bot)).toThrow(/real linked human/); + }); + it("refuses arbitrary Slack methods, another app/channel and unscoped post requests",()=>{ + expect(()=>assertSlackCall(binding(),"sender","chat.postMessage",{channel:"CCHANNEL",text:"Exact original query"})).not.toThrow(); + for(const action of [()=>assertSlackCall(binding(),"sender","users.list",{}),()=>assertSlackCall(binding(),"sender","chat.postMessage",{channel:"COTHER"}),()=>assertSlackCall(binding(),"sender","chat.postMessage",{}),()=>assertSlackCall(binding(),"configuration","apps.manifest.update",{app_id:"AOTHER"})])expect(action).toThrow(); + }); + it("accepts exact canonical linked-human source identity across the actual public DTOs",()=>expect(slackIngressMatches(ingress())).toBe(true)); + it("rejects copied company hints, wrong sender/comment, wrong thread, lost canonical bytes and missing dispatch",()=>{ + for(const mutate of [ + (i:ReturnType)=>{i.endpoint.companyId="another-company";}, + (i:ReturnType)=>{i.receipt.provider.senderUserId="UBOT";}, + (i:ReturnType)=>{i.comment.authorUserId="another-human";}, + (i:ReturnType)=>{i.comment.body="A rewritten easier query.";}, + (i:ReturnType)=>{i.conversation.externalThreadId="slack:CCHANNEL:1793050009.123456";}, + (i:ReturnType)=>{i.conversation.externalConversationId="CCHANNEL";}, + (i:ReturnType)=>{i.binding.conversationId="another-conversation";}, + (i:ReturnType)=>{i.receipt.providerCalls=0;}, + ]){const i=ingress();mutate(i);expect(slackIngressMatches(i)).toBe(false);} + }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts new file mode 100644 index 00000000000..3c65dbe7207 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-slack-provider.ts @@ -0,0 +1,46 @@ +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import type { ChatConversation,ChatEndpoint,ExternalChannelBindingSummary } from "../../../packages/shared/src/types/chat-channels.js"; +import { selectedExternalCredential,type ProviderBinding } from "./external-fixtures.js"; + +export const slackCompanionFields=["workspaceId","channelId","senderUserId","botUserId","appId","registeredWebhookOrigin","botTokenEnv","senderTokenEnv","signingSecretEnv","configurationTokenEnv"] as const; +const methods={bot:new Set(["auth.test","conversations.info","conversations.replies","conversations.history","chat.postMessage","files.info"]),sender:new Set(["auth.test","chat.postMessage","files.getUploadURLExternal","files.completeUploadExternal"]),configuration:new Set(["apps.manifest.export","apps.manifest.update"])}; +export function assertSlackFixture(binding:ProviderBinding){ + const p=binding.prerequisites; + for(const field of slackCompanionFields)if(!p[field])throw new Error(`Setup blocker: Slack ${field} reference is required`); + for(const [field,pattern] of [["workspaceId",/^T[A-Z0-9]+$/],["channelId",/^[CG][A-Z0-9]+$/],["senderUserId",/^[UW][A-Z0-9]+$/],["botUserId",/^[UW][A-Z0-9]+$/],["appId",/^A[A-Z0-9]+$/]] as const){if(!pattern.test(p[field]!)||!binding.resourceIds.includes(p[field]!))throw new Error(`Setup blocker: Slack ${field} is not an explicitly allowlisted disposable provider resource`);} + const origin=new URL(p.registeredWebhookOrigin!);if(origin.protocol!=="https:"||origin.username||origin.password||origin.search||origin.hash||origin.pathname!=="/")throw new Error("Setup blocker: Slack requires an explicitly designated HTTPS webhook origin for this isolated attempt"); + if(p.botUserId===p.senderUserId)throw new Error("Setup blocker: canonical Slack sender must be the real linked human, not the app bot"); +} +export function assertSlackCall(binding:ProviderBinding,kind:keyof typeof methods,method:string,parameters:Record){ + assertSlackFixture(binding);if(!methods[kind].has(method))throw new Error("Slack fixture method exceeds its finite provider contract"); + const p=binding.prerequisites; + if("channel" in parameters&¶meters.channel!==p.channelId)throw new Error("Slack fixture call escaped its allowed channel"); + if("channel_id" in parameters&¶meters.channel_id!==p.channelId)throw new Error("Slack fixture upload escaped its allowed channel"); + if("app_id" in parameters&¶meters.app_id!==p.appId)throw new Error("Slack fixture call escaped its allowed disposable app"); + if((method==="apps.manifest.export"||method==="apps.manifest.update")&¶meters.app_id!==p.appId)throw new Error("Slack manifest operation requires the exact owned app"); + if(method==="chat.postMessage"&¶meters.channel!==p.channelId)throw new Error("Slack canonical input requires the exact allowed channel"); +} +/** Official Slack HTTP only. No fixture call occurs during import/calibration. */ +export function slackProvider(ctx:ChatCatalogContext,binding:ProviderBinding,fetcher:typeof fetch=fetch){ + assertSlackFixture(binding);let calls=0;const receipts:Array<{kind:string;method:string;ok:boolean}>=[]; + return {receipts,async call>(kind:keyof typeof methods,method:string,parameters:Record,signal?:AbortSignal):Promise{ + assertSlackCall(binding,kind,method,parameters);if(++calls>40)throw new Error("Slack fixture exceeded its declared bounded provider calls"); + const token=selectedExternalCredential(ctx,binding,kind==="bot"?"botTokenEnv":kind==="sender"?"senderTokenEnv":"configurationTokenEnv"); + const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(parameters)}); + if(!response.ok){receipts.push({kind,method,ok:false});await response.body?.cancel();throw new Error(`Slack fixture ${method} returned HTTP ${response.status}; private response withheld`);} + const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>2*1024*1024)throw new Error("Slack fixture response exceeds bounded metadata size"); + let result:T;try{result=JSON.parse(bytes.toString("utf8")) as T;}catch{throw new Error("Slack fixture returned invalid metadata; response withheld");} + receipts.push({kind,method,ok:result.ok===true});if(result.ok!==true)throw new Error(`Slack fixture ${method} was not accepted; provider error details withheld`);return result; + }}; +} +export interface SlackIngressReceipt {providerCalls:number;query:string;endpointId:string;conversationId:string;issueId:string;humanCommentId:string;linkedPaperclipUserId:string;provider:{workspaceId:string;channelId:string;threadId:string;canonicalMessageId:string;senderUserId:string}} +/** Source-qualified public DTO checks; externalThreadId is the SDK encoded key. */ +export function slackIngressMatches(input:{receipt:SlackIngressReceipt;endpoint:ChatEndpoint;conversation:ChatConversation;binding:ExternalChannelBindingSummary;comment:{id:string;body:string;authorUserId:string|null;metadata?:{sourceChannel?:string}};canonicalQuery:string;companyId:string;agentId:string}){ + const {receipt:r,endpoint:e,conversation:c,binding:b,comment}=input; + return Number.isSafeInteger(r.providerCalls)&&r.providerCalls>=1&&r.providerCalls<=20&&r.query===input.canonicalQuery&&e.id===r.endpointId&&e.companyId===input.companyId&&e.assignedAgentId===input.agentId&&e.provider==="slack"&&e.providerAccountId===r.provider.workspaceId + &&c.id===r.conversationId&&c.endpointId===e.id&&c.companyId===input.companyId&&c.issueId===r.issueId&&c.externalThreadId===`slack:${r.provider.channelId}:${r.provider.threadId}` + &&c.externalConversationId===`slack:${r.provider.channelId}` + &&b.endpointId===e.id&&b.conversationId===c.id&&b.provider==="slack"&&b.assignedAgentLocked===true + &&comment.id===r.humanCommentId&&comment.body===input.canonicalQuery&&comment.authorUserId===r.linkedPaperclipUserId&&comment.metadata?.sourceChannel==="slack" + &&/^\d+\.\d{6}$/.test(r.provider.threadId)&&/^\d+\.\d{6}$/.test(r.provider.canonicalMessageId)&&r.provider.senderUserId!==e.botExternalId; +}