PrintersPageDropPermission.test.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /**
  2. * The printer card's drop zone is gated on the permissions it actually uses (#2849).
  3. *
  4. * Dropping a file uploads it to the library and creates a queue item, so the
  5. * two permissions that matter are `library:upload` and `queue:create` -- the
  6. * same pair the card's Print button has always checked. The drop zone instead
  7. * checked `printers:control`, which the flow never exercises, so a user with
  8. * control but neither of the other two got the drop accepted, the file
  9. * uploaded, and then a 403 from the queue with an orphaned library row left
  10. * behind.
  11. */
  12. import React from 'react';
  13. import { describe, it, expect, vi, beforeEach } from 'vitest';
  14. import { screen, waitFor, fireEvent } from '@testing-library/react';
  15. import { http, HttpResponse } from 'msw';
  16. import { server } from '../mocks/server';
  17. const permissions = { granted: ['library:upload', 'queue:create'] as string[] };
  18. const mockUseAuth = {
  19. user: { id: 1, username: 'operator', permissions: [] as string[] },
  20. authEnabled: true,
  21. requiresSetup: false,
  22. loading: false,
  23. isAdmin: false,
  24. login: vi.fn(),
  25. loginWithToken: vi.fn(),
  26. logout: vi.fn(),
  27. refreshUser: vi.fn(),
  28. refreshAuth: vi.fn(),
  29. hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
  30. hasAnyPermission: vi.fn(() => true),
  31. hasAllPermissions: vi.fn(() => true),
  32. canModify: vi.fn(() => true),
  33. };
  34. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  35. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  36. return { ...actual, useAuth: () => mockUseAuth };
  37. });
  38. import { render } from '../utils';
  39. import { PrintersPage } from '../../pages/PrintersPage';
  40. const mockPrinter = {
  41. id: 1,
  42. name: 'X1C',
  43. ip_address: '192.168.1.100',
  44. serial_number: '01P00A000000001',
  45. access_code: '12345678',
  46. model: 'X1C',
  47. enabled: true,
  48. nozzle_diameter: 0.4,
  49. nozzle_type: 'stainless_steel',
  50. location: 'Workshop',
  51. auto_archive: true,
  52. created_at: '2024-01-01T00:00:00Z',
  53. updated_at: '2024-01-01T00:00:00Z',
  54. };
  55. const uploads: string[] = [];
  56. function renderPage() {
  57. server.use(
  58. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  59. http.get('/api/v1/printers/:id/status', () =>
  60. HttpResponse.json({
  61. connected: true,
  62. state: 'RUNNING',
  63. progress: 40,
  64. layer_num: 10,
  65. total_layers: 100,
  66. temperatures: { nozzle: 220, bed: 60, chamber: 30 },
  67. remaining_time: 600,
  68. filename: 'running.gcode',
  69. wifi_signal: -29,
  70. speed_level: 2,
  71. vt_tray: [],
  72. ams: [],
  73. })
  74. ),
  75. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  76. http.post('/api/v1/library/files', () => {
  77. uploads.push('upload');
  78. return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
  79. }),
  80. );
  81. render(<PrintersPage />);
  82. }
  83. async function card(): Promise<HTMLElement> {
  84. await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
  85. return document.getElementById('printer-card-1') as HTMLElement;
  86. }
  87. const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
  88. describe('PrintersPage — drop zone permissions (#2849)', () => {
  89. beforeEach(() => {
  90. uploads.length = 0;
  91. permissions.granted = ['library:upload', 'queue:create'];
  92. });
  93. it('accepts the drop when the user can upload and queue', async () => {
  94. renderPage();
  95. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  96. expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
  97. });
  98. it('refuses when the user cannot upload to the library', async () => {
  99. permissions.granted = ['queue:create'];
  100. renderPage();
  101. const el = await card();
  102. fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
  103. expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
  104. fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
  105. // Nothing is uploaded, so no orphaned library row is left behind.
  106. await waitFor(() => expect(uploads).toEqual([]));
  107. });
  108. it('refuses when the user cannot create queue items', async () => {
  109. // The case the old printers:control gate got wrong: the upload would have
  110. // succeeded and the queue POST then 403'd.
  111. permissions.granted = ['library:upload'];
  112. renderPage();
  113. const el = await card();
  114. fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
  115. expect(await screen.findByText('You do not have permission to add to queue')).toBeInTheDocument();
  116. fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
  117. await waitFor(() => expect(uploads).toEqual([]));
  118. });
  119. it('is not granted by printers:control alone', async () => {
  120. permissions.granted = ['printers:control'];
  121. renderPage();
  122. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  123. // printers:control grants neither, so the more specific upload message wins.
  124. expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
  125. expect(screen.queryByText('Drop to queue')).toBeNull();
  126. });
  127. });