@@ -3,7 +3,7 @@ import type { WebClient } from "@slack/web-api";
33import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts" ;
44import { beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
55
6- const resolveSlackMedia = vi . fn ( ) ;
6+ const resolveSlackMedia = vi . fn < typeof import ( "./monitor/media.js" ) . resolveSlackMedia > ( ) ;
77const createSlackLookupClientMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
88
99vi . mock ( "./monitor/media.js" , ( ) => ( {
@@ -35,6 +35,7 @@ function makeSlackFileInfo(overrides?: Record<string, unknown>) {
3535 name : "image.png" ,
3636 mimetype : "image/png" ,
3737 url_private_download : "https://files.slack.com/files-pri/T1-F123/image.png" ,
38+ channels : [ "C123" ] ,
3839 ...overrides ,
3940 } ;
4041}
@@ -53,6 +54,14 @@ function expectNoMediaDownload(result: Awaited<ReturnType<typeof downloadSlackFi
5354 expect ( resolveSlackMedia ) . not . toHaveBeenCalled ( ) ;
5455}
5556
57+ function requireRefreshedFileAdmission ( ) {
58+ const admission = resolveSlackMedia . mock . calls [ 0 ] ?. [ 0 ] . isRefreshedFileAllowed ;
59+ if ( ! admission ) {
60+ throw new Error ( "Expected refreshed Slack file admission" ) ;
61+ }
62+ return admission ;
63+ }
64+
5665function expectResolveSlackMediaCalledWithDefaults ( client : ReturnType < typeof createClient > ) {
5766 expect ( resolveSlackMedia ) . toHaveBeenCalledWith ( {
5867 files : [
@@ -65,6 +74,7 @@ function expectResolveSlackMediaCalledWithDefaults(client: ReturnType<typeof cre
6574 } ,
6675 ] ,
6776 client,
77+ isRefreshedFileAllowed : expect . any ( Function ) ,
6878 token : "xoxb-test" ,
6979 maxBytes : 1024 ,
7080 } ) ;
@@ -100,6 +110,7 @@ describe("downloadSlackFile", () => {
100110 client,
101111 token : "xoxb-test" ,
102112 maxBytes : 1024 ,
113+ channelId : "C123" ,
103114 } ) ;
104115
105116 expect ( result ) . toBeNull ( ) ;
@@ -114,6 +125,7 @@ describe("downloadSlackFile", () => {
114125 client,
115126 token : "xoxb-test" ,
116127 maxBytes : 1024 ,
128+ channelId : "C123" ,
117129 } ) ;
118130
119131 expect ( client . files . info ) . toHaveBeenCalledWith ( { file : "F123" } ) ;
@@ -134,6 +146,7 @@ describe("downloadSlackFile", () => {
134146 client,
135147 token : "xoxb-test" ,
136148 maxBytes : 1024 ,
149+ channelId : "C123" ,
137150 } ) ;
138151
139152 expect ( resolveSlackMedia ) . toHaveBeenCalledWith ( expect . objectContaining ( { client } ) ) ;
@@ -160,6 +173,7 @@ describe("downloadSlackFile", () => {
160173 client,
161174 token : "xoxb-test" ,
162175 maxBytes : 1024 ,
176+ channelId : "C123" ,
163177 } ) ;
164178
165179 expect ( resolveSlackMedia ) . toHaveBeenCalledWith ( {
@@ -173,6 +187,7 @@ describe("downloadSlackFile", () => {
173187 } ,
174188 ] ,
175189 client,
190+ isRefreshedFileAllowed : expect . any ( Function ) ,
176191 token : "xoxb-test" ,
177192 maxBytes : 1024 ,
178193 } ) ;
@@ -201,6 +216,55 @@ describe("downloadSlackFile", () => {
201216 expectNoMediaDownload ( result ) ;
202217 } ) ;
203218
219+ it . each ( [
220+ { name : "public channel metadata" , file : { channels : [ "C123" ] } } ,
221+ { name : "private channel metadata" , file : { groups : [ "C123" ] } } ,
222+ { name : "DM metadata" , file : { ims : [ "C123" ] } } ,
223+ {
224+ name : "share metadata" ,
225+ file : {
226+ channels : undefined ,
227+ shares : { private : { C123 : [ { ts : "111.111" } ] } } ,
228+ } ,
229+ } ,
230+ ] ) ( "downloads when $name proves the requested channel" , async ( { file } ) => {
231+ const client = createClient ( ) ;
232+ client . files . info . mockResolvedValueOnce ( {
233+ file : makeSlackFileInfo ( file ) ,
234+ } ) ;
235+ resolveSlackMedia . mockResolvedValueOnce ( [ makeResolvedSlackMedia ( ) ] ) ;
236+
237+ const result = await downloadSlackFile ( "F123" , {
238+ client,
239+ token : "xoxb-test" ,
240+ maxBytes : 1024 ,
241+ channelId : "C123" ,
242+ } ) ;
243+
244+ expect ( result ) . toEqual ( makeResolvedSlackMedia ( ) ) ;
245+ } ) ;
246+
247+ it ( "accepts positive channel proof even when Slack reports additional shares" , async ( ) => {
248+ const client = createClient ( ) ;
249+ client . files . info . mockResolvedValueOnce ( {
250+ file : makeSlackFileInfo ( {
251+ channels : [ "C123" ] ,
252+ has_more_shares : true ,
253+ skipped_shares : true ,
254+ } ) ,
255+ } ) ;
256+ resolveSlackMedia . mockResolvedValueOnce ( [ makeResolvedSlackMedia ( ) ] ) ;
257+
258+ const result = await downloadSlackFile ( "F123" , {
259+ client,
260+ token : "xoxb-test" ,
261+ maxBytes : 1024 ,
262+ channelId : "C123" ,
263+ } ) ;
264+
265+ expect ( result ) . toEqual ( makeResolvedSlackMedia ( ) ) ;
266+ } ) ;
267+
204268 it ( "returns null when thread scope definitely mismatches file share thread" , async ( ) => {
205269 const client = createClient ( ) ;
206270 client . files . info . mockResolvedValueOnce ( {
@@ -224,9 +288,11 @@ describe("downloadSlackFile", () => {
224288 expectNoMediaDownload ( result ) ;
225289 } ) ;
226290
227- it ( "keeps legacy behavior when file metadata does not expose channel/thread shares " , async ( ) => {
291+ it ( "returns null when file metadata proves the channel but not the requested thread " , async ( ) => {
228292 const client = createClient ( ) ;
229- mockSuccessfulMediaDownload ( client ) ;
293+ client . files . info . mockResolvedValueOnce ( {
294+ file : makeSlackFileInfo ( { channels : [ "C123" ] } ) ,
295+ } ) ;
230296
231297 const result = await downloadSlackFile ( "F123" , {
232298 client,
@@ -236,9 +302,105 @@ describe("downloadSlackFile", () => {
236302 threadId : "222.222" ,
237303 } ) ;
238304
305+ expectNoMediaDownload ( result ) ;
306+ } ) ;
307+
308+ it . each ( [
309+ { name : "share message timestamp" , share : { ts : "111.111" } } ,
310+ { name : "thread timestamp" , share : { ts : "222.222" , thread_ts : "111.111" } } ,
311+ ] ) ( "downloads when $name proves the requested thread" , async ( { share } ) => {
312+ const client = createClient ( ) ;
313+ client . files . info . mockResolvedValueOnce ( {
314+ file : makeSlackFileInfo ( {
315+ shares : { private : { C123 : [ share ] } } ,
316+ } ) ,
317+ } ) ;
318+ resolveSlackMedia . mockResolvedValueOnce ( [ makeResolvedSlackMedia ( ) ] ) ;
319+
320+ const result = await downloadSlackFile ( "F123" , {
321+ client,
322+ token : "xoxb-test" ,
323+ maxBytes : 1024 ,
324+ channelId : "C123" ,
325+ threadId : "111.111" ,
326+ } ) ;
327+
239328 expect ( result ) . toEqual ( makeResolvedSlackMedia ( ) ) ;
240- expect ( resolveSlackMedia ) . toHaveBeenCalledTimes ( 1 ) ;
241- expectResolveSlackMediaCalledWithDefaults ( client ) ;
329+ } ) ;
330+
331+ it ( "reapplies the requested channel and thread scope to refreshed metadata" , async ( ) => {
332+ const client = createClient ( ) ;
333+ client . files . info . mockResolvedValueOnce ( {
334+ file : makeSlackFileInfo ( {
335+ shares : { private : { C123 : [ { ts : "111.111" } ] } } ,
336+ } ) ,
337+ } ) ;
338+ resolveSlackMedia . mockResolvedValueOnce ( [ makeResolvedSlackMedia ( ) ] ) ;
339+
340+ await downloadSlackFile ( "F123" , {
341+ client,
342+ token : "xoxb-test" ,
343+ maxBytes : 1024 ,
344+ channelId : "C123" ,
345+ threadId : "111.111" ,
346+ } ) ;
347+
348+ const isAllowed = requireRefreshedFileAdmission ( ) ;
349+ expect (
350+ isAllowed ( makeSlackFileInfo ( { shares : { private : { C123 : [ { ts : "111.111" } ] } } } ) ) ,
351+ ) . toBe ( true ) ;
352+ expect (
353+ isAllowed ( makeSlackFileInfo ( { shares : { private : { C999 : [ { ts : "111.111" } ] } } } ) ) ,
354+ ) . toBe ( false ) ;
355+ expect (
356+ isAllowed ( makeSlackFileInfo ( { shares : { private : { C123 : [ { ts : "222.222" } ] } } } ) ) ,
357+ ) . toBe ( false ) ;
358+ } ) ;
359+
360+ it . each ( [
361+ { name : "absent channel/share evidence" , file : { channels : undefined } } ,
362+ {
363+ name : "malformed shares container" ,
364+ file : { channels : undefined , shares : "invalid" } ,
365+ } ,
366+ {
367+ name : "requested channel with a non-array share value" ,
368+ file : { channels : undefined , shares : { private : { C123 : { } } } } ,
369+ } ,
370+ {
371+ name : "requested channel with an empty share array" ,
372+ file : { channels : undefined , shares : { private : { C123 : [ ] } } } ,
373+ } ,
374+ {
375+ name : "requested channel with a share entry lacking timestamps" ,
376+ file : { channels : undefined , shares : { private : { C123 : [ { } ] } } } ,
377+ } ,
378+ ] ) ( "returns null for $name" , async ( { file } ) => {
379+ const client = createClient ( ) ;
380+ client . files . info . mockResolvedValueOnce ( { file : makeSlackFileInfo ( file ) } ) ;
381+
382+ const result = await downloadSlackFile ( "F123" , {
383+ client,
384+ token : "xoxb-test" ,
385+ maxBytes : 1024 ,
386+ channelId : "C123" ,
387+ } ) ;
388+
389+ expectNoMediaDownload ( result ) ;
390+ } ) ;
391+
392+ it ( "returns null when the requested channel is empty after normalization" , async ( ) => {
393+ const client = createClient ( ) ;
394+ mockSuccessfulMediaDownload ( client ) ;
395+
396+ const result = await downloadSlackFile ( "F123" , {
397+ client,
398+ token : "xoxb-test" ,
399+ maxBytes : 1024 ,
400+ channelId : " " ,
401+ } ) ;
402+
403+ expectNoMediaDownload ( result ) ;
242404 } ) ;
243405
244406 it ( "resolves the bot token from cfg when no explicit token or client is provided" , async ( ) => {
@@ -265,6 +427,7 @@ describe("downloadSlackFile", () => {
265427 cfg,
266428 accountId : "default" ,
267429 maxBytes : 1024 ,
430+ channelId : "C123" ,
268431 } ) ;
269432
270433 expect ( createSlackLookupClientMock ) . toHaveBeenCalledWith ( "xoxb-from-cfg" , {
@@ -281,6 +444,7 @@ describe("downloadSlackFile", () => {
281444 } ,
282445 ] ,
283446 client,
447+ isRefreshedFileAllowed : expect . any ( Function ) ,
284448 token : "xoxb-from-cfg" ,
285449 maxBytes : 1024 ,
286450 } ) ;
0 commit comments