FileManagerFolderDelete.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. /**
  2. * Tests for folder deletion permission gating in the File Manager tree (#1781).
  3. *
  4. * Users with only library:delete_own may delete empty, unlinked, non-external
  5. * folders; everything else stays behind library:delete_all.
  6. */
  7. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  8. import { screen, waitFor, within } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { http, HttpResponse } from 'msw';
  11. import { render } from '../utils';
  12. import { server } from '../mocks/server';
  13. import { FileManagerPage } from '../../pages/FileManagerPage';
  14. import { setAuthToken } from '../../api/client';
  15. const mockFolders = [
  16. {
  17. id: 1,
  18. name: 'EmptyOne',
  19. parent_id: null,
  20. file_count: 0,
  21. project_id: null,
  22. archive_id: null,
  23. project_name: null,
  24. archive_name: null,
  25. is_external: false,
  26. children: [],
  27. },
  28. {
  29. id: 2,
  30. name: 'HasFiles',
  31. parent_id: null,
  32. file_count: 3,
  33. project_id: null,
  34. archive_id: null,
  35. project_name: null,
  36. archive_name: null,
  37. is_external: false,
  38. children: [],
  39. },
  40. {
  41. id: 3,
  42. name: 'LinkedEmpty',
  43. parent_id: null,
  44. file_count: 0,
  45. project_id: 1,
  46. archive_id: null,
  47. project_name: 'My Project',
  48. archive_name: null,
  49. is_external: false,
  50. children: [],
  51. },
  52. ];
  53. function mockAuthUser(permissions: string[]) {
  54. setAuthToken('test-token', 'session');
  55. server.use(
  56. http.get('*/api/v1/auth/status', () =>
  57. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  58. ),
  59. http.get('*/api/v1/auth/me', () =>
  60. HttpResponse.json({
  61. id: 7,
  62. username: 'operator1',
  63. is_admin: false,
  64. permissions,
  65. }),
  66. ),
  67. );
  68. }
  69. async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
  70. // Walk up to the row itself rather than assuming the name is its direct
  71. // child — the name sits in a wrapper that also holds the optional
  72. // last-activity line (#2680).
  73. const row = screen.getByText(folderName).closest('div.group')!;
  74. const buttons = within(row).getAllByRole('button');
  75. // The kebab (MoreVertical) menu toggle is the last button in the row
  76. await user.click(buttons[buttons.length - 1]);
  77. return row;
  78. }
  79. describe('FileManager folder deletion gating (#1781)', () => {
  80. beforeEach(() => {
  81. localStorage.clear();
  82. server.use(
  83. http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
  84. http.get('/api/v1/library/files', () => HttpResponse.json([])),
  85. http.get('/api/v1/library/stats', () =>
  86. HttpResponse.json({
  87. total_files: 3,
  88. total_folders: 3,
  89. total_size_bytes: 1024,
  90. disk_free_bytes: 10737418240,
  91. disk_total_bytes: 107374182400,
  92. }),
  93. ),
  94. http.get('/api/v1/projects/', () => HttpResponse.json([{ id: 1, name: 'My Project', color: '#00ae42' }])),
  95. http.get('/api/v1/archives/', () => HttpResponse.json([])),
  96. );
  97. });
  98. afterEach(() => {
  99. setAuthToken(null);
  100. });
  101. it('enables delete on an empty folder for a delete_own user', async () => {
  102. mockAuthUser(['library:read_own', 'library:delete_own']);
  103. render(<FileManagerPage />);
  104. await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
  105. const user = userEvent.setup();
  106. const row = await openFolderMenu(user, 'EmptyOne');
  107. const deleteButton = within(row).getByRole('button', { name: 'Delete' });
  108. expect(deleteButton).not.toBeDisabled();
  109. });
  110. it('disables delete on a non-empty folder for a delete_own user, with own-folders tooltip', async () => {
  111. mockAuthUser(['library:read_own', 'library:delete_own']);
  112. render(<FileManagerPage />);
  113. await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
  114. const user = userEvent.setup();
  115. const row = await openFolderMenu(user, 'HasFiles');
  116. const deleteButton = within(row).getByRole('button', { name: 'Delete' });
  117. expect(deleteButton).toBeDisabled();
  118. expect(deleteButton).toHaveAttribute(
  119. 'title',
  120. 'You can only delete your own folders when everything in them is yours, or empty folders without an owner',
  121. );
  122. });
  123. it('disables delete on a linked folder for a delete_own user, with no-permission tooltip', async () => {
  124. mockAuthUser(['library:read_own', 'library:delete_own']);
  125. render(<FileManagerPage />);
  126. await waitFor(() => expect(screen.getByText('LinkedEmpty')).toBeInTheDocument());
  127. const user = userEvent.setup();
  128. const row = await openFolderMenu(user, 'LinkedEmpty');
  129. const deleteButton = within(row).getByRole('button', { name: 'Delete' });
  130. expect(deleteButton).toBeDisabled();
  131. expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
  132. });
  133. it('disables delete entirely for a user without any delete permission', async () => {
  134. mockAuthUser(['library:read_own']);
  135. render(<FileManagerPage />);
  136. await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
  137. const user = userEvent.setup();
  138. const row = await openFolderMenu(user, 'EmptyOne');
  139. const deleteButton = within(row).getByRole('button', { name: 'Delete' });
  140. expect(deleteButton).toBeDisabled();
  141. expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
  142. });
  143. it('keeps delete enabled on non-empty folders for a delete_all user', async () => {
  144. mockAuthUser(['library:read_all', 'library:delete_all']);
  145. render(<FileManagerPage />);
  146. await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
  147. const user = userEvent.setup();
  148. const row = await openFolderMenu(user, 'HasFiles');
  149. const deleteButton = within(row).getByRole('button', { name: 'Delete' });
  150. expect(deleteButton).not.toBeDisabled();
  151. });
  152. });
  153. // #3201: the backend says per folder what the current user may do with it.
  154. function ownedFolder(overrides: Record<string, unknown>) {
  155. return {
  156. parent_id: null,
  157. file_count: 0,
  158. project_id: null,
  159. archive_id: null,
  160. project_name: null,
  161. archive_name: null,
  162. is_external: false,
  163. external_path: null,
  164. external_readonly: false,
  165. latest_activity_at: null,
  166. created_by_id: 7,
  167. shared: false,
  168. can_write: true,
  169. can_rename: true,
  170. can_delete: true,
  171. children: [],
  172. ...overrides,
  173. };
  174. }
  175. describe('FileManager folder ownership (#3201)', () => {
  176. const folders = [
  177. ownedFolder({ id: 10, name: 'MyModels', file_count: 4 }),
  178. ownedFolder({ id: 11, name: 'ClassProject', created_by_id: 1, shared: true, can_rename: false, can_delete: false }),
  179. ownedFolder({ id: 12, name: 'PassThrough', created_by_id: 1, can_write: false, can_rename: false, can_delete: false }),
  180. ];
  181. let sharedBody: unknown = null;
  182. beforeEach(() => {
  183. localStorage.clear();
  184. sharedBody = null;
  185. server.use(
  186. http.get('/api/v1/library/folders', () => HttpResponse.json(folders)),
  187. http.get('/api/v1/library/files', () => HttpResponse.json([])),
  188. http.get('/api/v1/library/stats', () =>
  189. HttpResponse.json({
  190. total_files: 4,
  191. total_folders: 3,
  192. total_size_bytes: 1024,
  193. disk_free_bytes: 10737418240,
  194. disk_total_bytes: 107374182400,
  195. }),
  196. ),
  197. http.put('/api/v1/library/folders/:id', async ({ request, params }) => {
  198. sharedBody = await request.json();
  199. return HttpResponse.json(ownedFolder({ id: Number(params.id), name: 'x', created_at: '', updated_at: '' }));
  200. }),
  201. );
  202. });
  203. afterEach(() => {
  204. setAuthToken(null);
  205. });
  206. it('lets an owner rename and delete their own non-empty folder', async () => {
  207. mockAuthUser(['library:read_own', 'library:update_own', 'library:delete_own']);
  208. render(<FileManagerPage />);
  209. await waitFor(() => expect(screen.getByText('MyModels')).toBeInTheDocument());
  210. const user = userEvent.setup();
  211. const row = await openFolderMenu(user, 'MyModels');
  212. expect(within(row).getByRole('button', { name: 'Rename' })).not.toBeDisabled();
  213. expect(within(row).getByRole('button', { name: 'Delete' })).not.toBeDisabled();
  214. // Sharing stays with library:update_all.
  215. expect(within(row).queryByRole('button', { name: 'Share with everyone' })).not.toBeInTheDocument();
  216. });
  217. it("disables rename on someone else's shared folder and marks it shared", async () => {
  218. mockAuthUser(['library:read_own', 'library:update_own', 'library:delete_own']);
  219. render(<FileManagerPage />);
  220. await waitFor(() => expect(screen.getByText('ClassProject')).toBeInTheDocument());
  221. const user = userEvent.setup();
  222. const row = await openFolderMenu(user, 'ClassProject');
  223. expect(within(row).getByTitle('Shared with everyone')).toBeInTheDocument();
  224. expect(within(row).getByRole('button', { name: 'Rename' })).toBeDisabled();
  225. });
  226. it('lets an admin share a folder', async () => {
  227. mockAuthUser(['library:read_all', 'library:update_all']);
  228. render(<FileManagerPage />);
  229. await waitFor(() => expect(screen.getByText('MyModels')).toBeInTheDocument());
  230. const user = userEvent.setup();
  231. const row = await openFolderMenu(user, 'MyModels');
  232. await user.click(within(row).getByRole('button', { name: 'Share with everyone' }));
  233. await waitFor(() => expect(sharedBody).toEqual({ shared: true }));
  234. });
  235. it('offers to stop sharing a shared folder', async () => {
  236. mockAuthUser(['library:read_all', 'library:update_all']);
  237. render(<FileManagerPage />);
  238. await waitFor(() => expect(screen.getByText('ClassProject')).toBeInTheDocument());
  239. const user = userEvent.setup();
  240. const row = await openFolderMenu(user, 'ClassProject');
  241. await user.click(within(row).getByRole('button', { name: 'Stop sharing' }));
  242. await waitFor(() => expect(sharedBody).toEqual({ shared: false }));
  243. });
  244. it('disables upload in a folder the user can only pass through', async () => {
  245. mockAuthUser(['library:read_own', 'library:upload']);
  246. render(<FileManagerPage />);
  247. await waitFor(() => expect(screen.getByText('PassThrough')).toBeInTheDocument());
  248. const user = userEvent.setup();
  249. const upload = screen.getByRole('button', { name: 'Upload' });
  250. expect(upload).not.toBeDisabled();
  251. await user.click(screen.getByText('PassThrough'));
  252. await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled());
  253. expect(screen.getByRole('button', { name: 'Upload' })).toHaveAttribute(
  254. 'title',
  255. 'You can only add to your own folders and folders shared with everyone',
  256. );
  257. expect(screen.getByRole('button', { name: 'New Folder' })).toBeDisabled();
  258. await user.click(screen.getByText('ClassProject'));
  259. await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).not.toBeDisabled());
  260. });
  261. });