cozy-sharing 33.3.4 → 33.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,10 @@ import React from 'react';
2
2
  import { useClient, useQuery, hasQueryBeenLoaded } from 'cozy-client';
3
3
  import MemberRecipientLite from './MemberRecipientLite';
4
4
  import withLocales from '../../hoc/withLocales';
5
- import { buildInstanceSettingsQuery } from '../../queries/queries';
5
+ import { buildInstanceSettingsQuery } from '../../queries/queries'; // See OwnerRecipientDefault: show the instance public avatar (with an initials
6
+ // fallback) when the owner has no per-sharing avatar to reference.
7
+
8
+ var OWNER_PUBLIC_AVATAR_PATH = '/public/avatar?fallback=initials';
6
9
 
7
10
  var OwnerRecipientDefaultLite = function OwnerRecipientDefaultLite() {
8
11
  var _instanceSettingsResu;
@@ -14,6 +17,7 @@ var OwnerRecipientDefaultLite = function OwnerRecipientDefaultLite() {
14
17
  recipient: {
15
18
  status: 'owner',
16
19
  instance: client.options.uri,
20
+ avatarPath: OWNER_PUBLIC_AVATAR_PATH,
17
21
  public_name: instanceSettingsResult === null || instanceSettingsResult === void 0 || (_instanceSettingsResu = instanceSettingsResult.data) === null || _instanceSettingsResu === void 0 || (_instanceSettingsResu = _instanceSettingsResu.attributes) === null || _instanceSettingsResu === void 0 ? void 0 : _instanceSettingsResu.public_name
18
22
  },
19
23
  isOwner: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-sharing",
3
- "version": "33.3.4",
3
+ "version": "33.4.1",
4
4
  "description": "Provides sharing login for React applications.",
5
5
  "main": "dist/index.js",
6
6
  "author": "Cozy",
@@ -53,7 +53,7 @@
53
53
  "babel-jest": "30.3.0",
54
54
  "babel-plugin-css-modules-transform": "1.6.2",
55
55
  "babel-plugin-inline-react-svg": "1.1.2",
56
- "cozy-client": "^60.27.0",
56
+ "cozy-client": "^60.28.0",
57
57
  "cozy-device-helper": "^4.0.0",
58
58
  "cozy-intent": "^2.31.1",
59
59
  "cozy-minilog": "^3.10.1",
@@ -68,7 +68,7 @@
68
68
  "twake-i18n": "^0.4.1"
69
69
  },
70
70
  "peerDependencies": {
71
- "cozy-client": ">=60.27.0",
71
+ "cozy-client": ">=60.28.0",
72
72
  "cozy-device-helper": ">=4.0.0",
73
73
  "cozy-flags": ">=4.8.1",
74
74
  "cozy-intent": ">=2.29.1",
@@ -83,5 +83,5 @@
83
83
  "sideEffects": [
84
84
  "*.css"
85
85
  ],
86
- "gitHead": "05ad9ddb799d8a239d336c0a31c3a87552394d48"
86
+ "gitHead": "a2e29d6c728ebf0fdc890666a9ec3505e90551ed"
87
87
  }
@@ -87,6 +87,7 @@ export class SharingProvider extends Component {
87
87
  revokeSelf: this.revokeSelf,
88
88
  shareByLink: this.shareByLink,
89
89
  getFederatedShareLink: this.getFederatedShareLink,
90
+ fetchSharedDriveSharingLinks: this.fetchSharedDriveSharingLinks,
90
91
  updateDocumentPermissions: this.updateDocumentPermissions,
91
92
  revokeSharingLink: this.revokeSharingLink,
92
93
  hasLoadedAtLeastOnePage: false,
@@ -373,17 +374,30 @@ export class SharingProvider extends Component {
373
374
  }
374
375
 
375
376
  shareByLink = async (document, options) => {
376
- const resp = document.driveId
377
- ? await this.createSharedDriveSharingLink(document, options)
378
- : await this.permissionCol.createSharingLink(document, options)
379
- this.dispatch(addSharingLink(resp.data))
380
- return resp
377
+ try {
378
+ const resp = document.driveId
379
+ ? await this.createSharedDriveSharingLink(document, options)
380
+ : await this.permissionCol.createSharingLink(document, options)
381
+ this.dispatch(addSharingLink(resp.data))
382
+ return resp
383
+ } catch (error) {
384
+ if (document.driveId && error && error.status === 409) {
385
+ const permissions = await this.fetchSharedDriveSharingLinks(document)
386
+ if (permissions && permissions.length > 0) {
387
+ return { data: permissions[0] }
388
+ }
389
+ }
390
+ throw error
391
+ }
381
392
  }
382
393
 
383
394
  getFederatedShareLink = document => {
384
- if (!document.driveId) return null
395
+ if (!document?.driveId) return null
396
+
397
+ const documentId = document._id || document.id
398
+ if (!documentId) return null
385
399
 
386
- const permissions = getDocumentPermissions(this.state, document._id)
400
+ const permissions = getDocumentPermissions(this.state, documentId)
387
401
  const perm = permissions.find(p => getShortcode(p))
388
402
  if (!perm) return null
389
403
 
@@ -413,6 +427,34 @@ export class SharingProvider extends Component {
413
427
  return resp
414
428
  }
415
429
 
430
+ fetchSharedDriveSharingLinks = async document => {
431
+ if (!document || !document.driveId) return []
432
+
433
+ const { client } = this.props
434
+ const drivePermissionCollection = client.collection('io.cozy.permissions', {
435
+ driveId: document.driveId
436
+ })
437
+ const documentId = document._id || document.id
438
+ if (!documentId) return []
439
+
440
+ const resp = await drivePermissionCollection.findLinksByIds([documentId])
441
+ const permissions = resp.data || []
442
+
443
+ const existingPermissions = getDocumentPermissions(this.state, documentId)
444
+ const existingPermissionIds = existingPermissions.map(
445
+ permission => permission.id
446
+ )
447
+ const newPermissions = permissions.filter(
448
+ permission => !existingPermissionIds.includes(permission.id)
449
+ )
450
+
451
+ if (newPermissions.length > 0) {
452
+ this.dispatch(addSharingLink(newPermissions))
453
+ }
454
+
455
+ return permissions
456
+ }
457
+
416
458
  updateSharedDrivePermissions = async (
417
459
  document,
418
460
  permissionDocument,
@@ -291,6 +291,210 @@ describe('updateDocumentPermissions', () => {
291
291
  })
292
292
  })
293
293
 
294
+ describe('fetchSharedDriveSharingLinks', () => {
295
+ const PERM_DRIVE_FILE = {
296
+ type: 'io.cozy.permissions',
297
+ id: 'perm_drive_file',
298
+ attributes: {
299
+ type: 'share',
300
+ permissions: {
301
+ rule0: {
302
+ type: 'io.cozy.files',
303
+ verbs: ['GET'],
304
+ values: ['file_in_drive']
305
+ }
306
+ },
307
+ shortcodes: { code: 'shortcode123' }
308
+ }
309
+ }
310
+
311
+ const driveFile = {
312
+ _id: 'file_in_drive',
313
+ id: 'file_in_drive',
314
+ driveId: 'drive_123'
315
+ }
316
+
317
+ it('fetches shared drive sharing links by file id', async () => {
318
+ const mockFindLinksByIds = jest
319
+ .fn()
320
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
321
+ const mockClient = createMockClient({})
322
+ mockClient.collection = jest.fn().mockReturnValue({
323
+ findLinksByIds: mockFindLinksByIds
324
+ })
325
+
326
+ const provider = new SharingProvider({ client: mockClient })
327
+ provider.state = reducer()
328
+ provider.dispatch = jest.fn(action => {
329
+ provider.state = reducer(provider.state, action)
330
+ })
331
+
332
+ const result = await provider.fetchSharedDriveSharingLinks(driveFile)
333
+
334
+ expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.permissions', {
335
+ driveId: 'drive_123'
336
+ })
337
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
338
+ expect(provider.dispatch).toHaveBeenCalled()
339
+ const permissions = getDocumentPermissions(provider.state, 'file_in_drive')
340
+ expect(permissions).toHaveLength(1)
341
+ expect(permissions[0].id).toBe(PERM_DRIVE_FILE.id)
342
+ expect(result).toEqual([PERM_DRIVE_FILE])
343
+ })
344
+
345
+ it('returns an empty array and skips the API call for non-shared-drive documents', async () => {
346
+ const mockClient = createMockClient({})
347
+ const findLinksByIds = jest.fn()
348
+ mockClient.collection = jest.fn().mockReturnValue({
349
+ findLinksByIds
350
+ })
351
+
352
+ const provider = new SharingProvider({ client: mockClient })
353
+ provider.dispatch = jest.fn()
354
+
355
+ const result = await provider.fetchSharedDriveSharingLinks({
356
+ _id: 'regular_file'
357
+ })
358
+
359
+ expect(result).toEqual([])
360
+ expect(findLinksByIds).not.toHaveBeenCalled()
361
+ expect(provider.dispatch).not.toHaveBeenCalled()
362
+ })
363
+
364
+ it('uses document.id as a fallback when _id is missing', async () => {
365
+ const mockFindLinksByIds = jest
366
+ .fn()
367
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
368
+ const mockClient = createMockClient({})
369
+ mockClient.collection = jest.fn().mockReturnValue({
370
+ findLinksByIds: mockFindLinksByIds
371
+ })
372
+
373
+ const provider = new SharingProvider({ client: mockClient })
374
+ provider.state = reducer()
375
+ provider.dispatch = jest.fn(action => {
376
+ provider.state = reducer(provider.state, action)
377
+ })
378
+
379
+ await provider.fetchSharedDriveSharingLinks({
380
+ id: 'file_in_drive',
381
+ driveId: 'drive_123'
382
+ })
383
+
384
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
385
+ })
386
+
387
+ it('does not dispatch when the response has no data', async () => {
388
+ const mockFindLinksByIds = jest.fn().mockResolvedValue({ data: [] })
389
+ const mockClient = createMockClient({})
390
+ mockClient.collection = jest.fn().mockReturnValue({
391
+ findLinksByIds: mockFindLinksByIds
392
+ })
393
+
394
+ const provider = new SharingProvider({ client: mockClient })
395
+ provider.dispatch = jest.fn()
396
+
397
+ const result = await provider.fetchSharedDriveSharingLinks(driveFile)
398
+
399
+ expect(result).toEqual([])
400
+ expect(provider.dispatch).not.toHaveBeenCalled()
401
+ })
402
+ })
403
+
404
+ describe('shareByLink shared drive 409 recovery', () => {
405
+ const PERM_DRIVE_FILE = {
406
+ type: 'io.cozy.permissions',
407
+ id: 'perm_drive_file',
408
+ attributes: {
409
+ type: 'share',
410
+ permissions: {
411
+ rule0: {
412
+ type: 'io.cozy.files',
413
+ verbs: ['GET'],
414
+ values: ['file_in_drive']
415
+ }
416
+ },
417
+ shortcodes: { code: 'shortcode123' }
418
+ }
419
+ }
420
+
421
+ const driveFile = {
422
+ _id: 'file_in_drive',
423
+ id: 'file_in_drive',
424
+ driveId: 'drive_123'
425
+ }
426
+
427
+ it('falls back to fetching existing links when create returns 409', async () => {
428
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
429
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
430
+ const mockFindLinksByIds = jest
431
+ .fn()
432
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
433
+ const mockClient = createMockClient({})
434
+ mockClient.collection = jest.fn().mockReturnValue({
435
+ createSharingLink: mockCreateSharingLink,
436
+ findLinksByIds: mockFindLinksByIds
437
+ })
438
+
439
+ const provider = new SharingProvider({ client: mockClient })
440
+ provider.state = reducer()
441
+ provider.dispatch = jest.fn(action => {
442
+ provider.state = reducer(provider.state, action)
443
+ })
444
+
445
+ const resp = await provider.shareByLink(driveFile, { verbs: ['GET'] })
446
+
447
+ expect(mockCreateSharingLink).toHaveBeenCalled()
448
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
449
+ expect(resp.data.id).toBe(PERM_DRIVE_FILE.id)
450
+ })
451
+
452
+ it('does not duplicate existing links when 409 recovery refetches them', async () => {
453
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
454
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
455
+ const mockFindLinksByIds = jest
456
+ .fn()
457
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
458
+ const mockClient = createMockClient({})
459
+ mockClient.collection = jest.fn().mockReturnValue({
460
+ createSharingLink: mockCreateSharingLink,
461
+ findLinksByIds: mockFindLinksByIds
462
+ })
463
+
464
+ const provider = new SharingProvider({ client: mockClient })
465
+ provider.state = reducer(undefined, addSharingLink(PERM_DRIVE_FILE))
466
+ provider.dispatch = jest.fn(action => {
467
+ provider.state = reducer(provider.state, action)
468
+ })
469
+
470
+ const resp = await provider.shareByLink(driveFile, { verbs: ['GET'] })
471
+
472
+ const permissions = getDocumentPermissions(provider.state, 'file_in_drive')
473
+ expect(resp.data.id).toBe(PERM_DRIVE_FILE.id)
474
+ expect(provider.dispatch).not.toHaveBeenCalled()
475
+ expect(permissions).toHaveLength(1)
476
+ })
477
+
478
+ it('rethrows the original error when 409 happens and no link is found', async () => {
479
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
480
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
481
+ const mockFindLinksByIds = jest.fn().mockResolvedValue({ data: [] })
482
+ const mockClient = createMockClient({})
483
+ mockClient.collection = jest.fn().mockReturnValue({
484
+ createSharingLink: mockCreateSharingLink,
485
+ findLinksByIds: mockFindLinksByIds
486
+ })
487
+
488
+ const provider = new SharingProvider({ client: mockClient })
489
+ provider.state = reducer()
490
+ provider.dispatch = jest.fn()
491
+
492
+ await expect(
493
+ provider.shareByLink(driveFile, { verbs: ['GET'] })
494
+ ).rejects.toBe(conflict)
495
+ })
496
+ })
497
+
294
498
  describe('updateSharingMemberType', () => {
295
499
  const mockSharing = {
296
500
  id: 'sharing-123',
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useState } from 'react'
2
2
 
3
3
  import { useClient } from 'cozy-client'
4
4
  import Avatar from 'cozy-ui/transpiled/react/Avatar'
@@ -8,6 +8,10 @@ import { getInitials } from '../../models'
8
8
 
9
9
  const MemberAvatar = ({ recipient, ...rest }) => {
10
10
  const client = useClient()
11
+ // Remember which image url failed to load so we can fall back to the
12
+ // initials. Storing the url (instead of a boolean) means a new url is
13
+ // retried automatically, without an effect to reset the error state.
14
+ const [erroredSrc, setErroredSrc] = useState(null)
11
15
 
12
16
  // There are cases when due to apparent memory leaks, the recipient does not exist
13
17
  // This will trigger an unhanded error in the Avatar component and crash the app
@@ -31,13 +35,24 @@ const MemberAvatar = ({ recipient, ...rest }) => {
31
35
  */
32
36
  const image =
33
37
  recipient.avatarPath && recipient.status
34
- ? `${client.options.uri}${recipient.avatarPath}?v=${recipient.status}`
38
+ ? `${client.options.uri}${recipient.avatarPath}${
39
+ recipient.avatarPath.includes('?') ? '&' : '?'
40
+ }v=${recipient.status}`
35
41
  : null
36
42
 
43
+ // The avatar can become unreachable (eg. the related sharing is revoked
44
+ // while its members are still rendered), in which case the image request
45
+ // fails. Without a fallback the browser shows a broken-image icon, so we
46
+ // render the initials instead.
37
47
  return (
38
48
  <Avatar {...rest}>
39
- {image ? (
40
- <img width="100%" height="100%" src={image} />
49
+ {image && erroredSrc !== image ? (
50
+ <img
51
+ width="100%"
52
+ height="100%"
53
+ src={image}
54
+ onError={() => setErroredSrc(image)}
55
+ />
41
56
  ) : (
42
57
  getInitials(recipient)
43
58
  )}
@@ -0,0 +1,80 @@
1
+ import { render, fireEvent } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { createMockClient } from 'cozy-client'
5
+
6
+ import { MemberAvatar } from './MemberAvatar'
7
+ import AppLike from '../../../test/AppLike'
8
+
9
+ describe('MemberAvatar', () => {
10
+ const client = createMockClient({})
11
+ client.options = { uri: 'http://cozy.example.com' }
12
+
13
+ const setup = recipient =>
14
+ render(
15
+ <AppLike client={client}>
16
+ <MemberAvatar recipient={recipient} />
17
+ </AppLike>
18
+ )
19
+
20
+ it('renders nothing when the recipient is missing', () => {
21
+ const { container } = setup(undefined)
22
+
23
+ // The component returns null; only the provider wrappers remain, with no
24
+ // avatar rendered inside.
25
+ expect(container.querySelector('[class*="MuiAvatar-root"]')).toBeNull()
26
+ expect(container.querySelector('img')).toBeNull()
27
+ })
28
+
29
+ it('renders the avatar image when the recipient has an avatarPath and a status', () => {
30
+ const { container } = setup({
31
+ public_name: 'Bob',
32
+ status: 'owner',
33
+ avatarPath: '/sharings/123/recipients/0/avatar'
34
+ })
35
+
36
+ const img = container.querySelector('img')
37
+ expect(img).toBeTruthy()
38
+ expect(img.getAttribute('src')).toBe(
39
+ 'http://cozy.example.com/sharings/123/recipients/0/avatar?v=owner'
40
+ )
41
+ })
42
+
43
+ it('appends the cache-busting param with & when avatarPath already has a query string', () => {
44
+ const { container } = setup({
45
+ public_name: 'Bob',
46
+ status: 'owner',
47
+ avatarPath: '/public/avatar?fallback=initials'
48
+ })
49
+
50
+ expect(container.querySelector('img').getAttribute('src')).toBe(
51
+ 'http://cozy.example.com/public/avatar?fallback=initials&v=owner'
52
+ )
53
+ })
54
+
55
+ it('renders the initials when there is no avatarPath', () => {
56
+ const { container, getByText } = setup({
57
+ public_name: 'Bob',
58
+ status: 'owner'
59
+ })
60
+
61
+ expect(container.querySelector('img')).toBeNull()
62
+ expect(getByText('B')).toBeTruthy()
63
+ })
64
+
65
+ it('falls back to the initials when the avatar image fails to load', () => {
66
+ const { container, getByText } = setup({
67
+ public_name: 'Bob',
68
+ status: 'owner',
69
+ avatarPath: '/sharings/123/recipients/0/avatar'
70
+ })
71
+
72
+ const img = container.querySelector('img')
73
+ expect(img).toBeTruthy()
74
+
75
+ fireEvent.error(img)
76
+
77
+ expect(container.querySelector('img')).toBeNull()
78
+ expect(getByText('B')).toBeTruthy()
79
+ })
80
+ })
@@ -1,7 +1,8 @@
1
1
  import PropTypes from 'prop-types'
2
- import React, { useCallback, useState } from 'react'
2
+ import React, { useCallback, useEffect, useState } from 'react'
3
3
 
4
4
  import { useClient } from 'cozy-client'
5
+ import minilog from 'cozy-minilog'
5
6
  import Button from 'cozy-ui/transpiled/react/Buttons'
6
7
  import { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
7
8
  import Typography from 'cozy-ui/transpiled/react/Typography'
@@ -21,6 +22,8 @@ import { default as DumbShareByEmail } from '../ShareByEmail'
21
22
  import ShareByLink from '../ShareByLink'
22
23
  import WhoHasAccess from '../WhoHasAccess'
23
24
 
25
+ const log = minilog('FederatedFolderModal')
26
+
24
27
  const FederatedFolderModalContent = ({
25
28
  onClose,
26
29
  document: existingDocument,
@@ -34,6 +37,8 @@ const FederatedFolderModalContent = ({
34
37
  getSharingById,
35
38
  getSharingLink,
36
39
  getFederatedShareLink,
40
+ getDocumentPermissions,
41
+ fetchSharedDriveSharingLinks,
37
42
  getRecipients,
38
43
  hasSharedChild,
39
44
  hasSharedParent,
@@ -56,6 +61,9 @@ const FederatedFolderModalContent = ({
56
61
  // in members before the modal closes. That's why when clicking on "Share"
57
62
  // we do not use the reactive existingDocument and existingRecipients.
58
63
  const [isSending, setIsSending] = useState(false)
64
+ const [isFetchingSharingLinks, setIsFetchingSharingLinks] = useState(false)
65
+ const [fetchedSharingLinksDocumentId, setFetchedSharingLinksDocumentId] =
66
+ useState(null)
59
67
  const [frozenDoc, setFrozenDoc] = useState(null)
60
68
  const [frozenRecipients, setFrozenRecipients] = useState(null)
61
69
 
@@ -169,13 +177,46 @@ const FederatedFolderModalContent = ({
169
177
  }
170
178
  }
171
179
 
172
- const existingRecipients = existingDocument
173
- ? getRecipients(existingDocument._id)
180
+ const documentId = existingDocument?._id || existingDocument?.id
181
+ const existingRecipients = documentId ? getRecipients(documentId) : []
182
+ const documentPermissions = documentId
183
+ ? getDocumentPermissions(documentId)
174
184
  : []
175
185
  const displayedLink = existingDocument?.driveId
176
186
  ? getFederatedShareLink(existingDocument)
177
187
  : getSharingLink(existingDocument?._id)
178
188
 
189
+ useEffect(() => {
190
+ if (!existingDocument?.driveId) return
191
+ if (!documentId) return
192
+ if (!fetchSharedDriveSharingLinks) return
193
+ if (documentPermissions.length > 0) return
194
+ if (isFetchingSharingLinks) return
195
+ if (fetchedSharingLinksDocumentId === documentId) return
196
+
197
+ const fetchSharingLinks = async () => {
198
+ setIsFetchingSharingLinks(true)
199
+
200
+ try {
201
+ await fetchSharedDriveSharingLinks(existingDocument)
202
+ } catch (error) {
203
+ log.error('Failed to fetch shared drive sharing links', error)
204
+ } finally {
205
+ setFetchedSharingLinksDocumentId(documentId)
206
+ setIsFetchingSharingLinks(false)
207
+ }
208
+ }
209
+
210
+ fetchSharingLinks()
211
+ }, [
212
+ documentId,
213
+ existingDocument,
214
+ documentPermissions.length,
215
+ fetchedSharingLinksDocumentId,
216
+ fetchSharedDriveSharingLinks,
217
+ isFetchingSharingLinks
218
+ ])
219
+
179
220
  const modalTitle = t('FederatedFolder.shareTitle', { name: folderName })
180
221
 
181
222
  return (
@@ -17,6 +17,8 @@ const mockGetRecipients = jest.fn().mockReturnValue([])
17
17
  const mockGetSharingById = jest.fn()
18
18
  const mockHasSharedChild = jest.fn()
19
19
  const mockHasSharedParent = jest.fn()
20
+ const mockFetchSharedDriveSharingLinks = jest.fn()
21
+ const mockGetDocumentPermissions = jest.fn().mockReturnValue([])
20
22
  const mockShowAlert = jest.fn()
21
23
  const mockOnClose = jest.fn()
22
24
 
@@ -27,7 +29,8 @@ jest.mock('../../hooks/useSharingContext', () => ({
27
29
  revoke: mockRevoke,
28
30
  getSharingLink: mockGetSharingLink,
29
31
  getFederatedShareLink: mockGetFederatedShareLink,
30
- getDocumentPermissions: jest.fn().mockReturnValue([]),
32
+ getDocumentPermissions: mockGetDocumentPermissions,
33
+ fetchSharedDriveSharingLinks: mockFetchSharedDriveSharingLinks,
31
34
  getOwner: jest.fn(),
32
35
  getRecipients: mockGetRecipients,
33
36
  getSharingById: mockGetSharingById,
@@ -65,7 +68,8 @@ const createSharingContextValue = () => ({
65
68
  hasWriteAccess: jest.fn(),
66
69
  getRecipients: jest.fn(),
67
70
  getSharingLink: mockGetSharingLink,
68
- getDocumentPermissions: jest.fn().mockReturnValue([]),
71
+ getDocumentPermissions: mockGetDocumentPermissions,
72
+ fetchSharedDriveSharingLinks: mockFetchSharedDriveSharingLinks,
69
73
  share: mockShare
70
74
  })
71
75
 
@@ -96,6 +100,8 @@ describe('FederatedFolderModal', () => {
96
100
 
97
101
  beforeEach(() => {
98
102
  jest.clearAllMocks()
103
+ mockGetDocumentPermissions.mockReturnValue([])
104
+ mockFetchSharedDriveSharingLinks.mockResolvedValue([])
99
105
  mockGetSharingLink.mockReturnValue('https://example.com/share/abc123')
100
106
  mockGetFederatedShareLink.mockReturnValue(
101
107
  'https://example.com/share/federated-abc123'
@@ -227,6 +233,85 @@ describe('FederatedFolderModal', () => {
227
233
  })
228
234
  })
229
235
 
236
+ describe('shared drive link fetch on mount', () => {
237
+ it('should fetch shared drive sharing links when document has a driveId and no permissions yet', async () => {
238
+ mockGetDocumentPermissions.mockReturnValue([])
239
+
240
+ setup({
241
+ document: { ...mockDocument, driveId: 'federated-folder-id' }
242
+ })
243
+
244
+ await waitFor(() => {
245
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
246
+ })
247
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledWith(
248
+ expect.objectContaining({
249
+ _id: mockDocument._id,
250
+ driveId: 'federated-folder-id'
251
+ })
252
+ )
253
+ })
254
+
255
+ it('should use document.id when _id is missing', async () => {
256
+ const document = {
257
+ id: 'folder-id-only',
258
+ name: 'My Test Folder',
259
+ path: '/test/folder',
260
+ driveId: 'federated-folder-id'
261
+ }
262
+
263
+ setup({ document })
264
+
265
+ await waitFor(() => {
266
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledWith(document)
267
+ })
268
+ expect(mockGetDocumentPermissions).toHaveBeenCalledWith('folder-id-only')
269
+ })
270
+
271
+ it('should not fetch shared drive sharing links when document has no driveId', async () => {
272
+ const { findByText } = setup({ document: mockDocument })
273
+
274
+ await findByText('Copy link')
275
+
276
+ expect(mockFetchSharedDriveSharingLinks).not.toHaveBeenCalled()
277
+ })
278
+
279
+ it('should not refetch after one attempt when permissions stay empty', async () => {
280
+ const document = { ...mockDocument, driveId: 'federated-folder-id' }
281
+ const { rerender } = setup({ document })
282
+
283
+ await waitFor(() => {
284
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
285
+ })
286
+
287
+ rerender(
288
+ <AppLike client={client} sharingContextValue={sharingContextValue}>
289
+ <FederatedFolderModal document={document} onClose={mockOnClose} />
290
+ </AppLike>
291
+ )
292
+
293
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
294
+ })
295
+
296
+ it('should not fetch shared drive sharing links when permissions are already loaded', async () => {
297
+ mockGetDocumentPermissions.mockReturnValue([
298
+ {
299
+ id: 'perm-1',
300
+ type: 'io.cozy.permissions',
301
+ attributes: { shortcodes: { code: 'abc' } }
302
+ }
303
+ ])
304
+
305
+ const { findByText } = setup({
306
+ document: { ...mockDocument, driveId: 'federated-folder-id' }
307
+ })
308
+
309
+ await findByText('Copy link')
310
+
311
+ expect(mockFetchSharedDriveSharingLinks).not.toHaveBeenCalled()
312
+ })
313
+ })
314
+
230
315
  describe('onSend callback', () => {
231
316
  const pendingRecipient = { name: 'Alice', email: 'alice@example.com' }
232
317