GitHubBackupSettingsPermissions.test.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. /**
  2. * The Git Restore button must respect github:restore client-side (#2656).
  3. *
  4. * All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
  5. * user without it gets a 403 the moment the modal opens its preview. Offering
  6. * the button anyway is an action that cannot work.
  7. *
  8. * Scoped to the button on purpose: the backup card itself stays visible,
  9. * because configuring backups is a separate permission.
  10. */
  11. import { describe, it, expect, afterEach } from 'vitest';
  12. import { screen, waitFor } from '@testing-library/react';
  13. import { http, HttpResponse } from 'msw';
  14. import { render } from '../utils';
  15. import { server } from '../mocks/server';
  16. import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
  17. import { setAuthToken } from '../../api/client';
  18. afterEach(() => {
  19. server.resetHandlers();
  20. setAuthToken(null);
  21. });
  22. /** A configured backup, which is what makes the action row render at all. */
  23. function mockConfiguredBackup() {
  24. server.use(
  25. http.get('*/api/v1/github-backup/config', () =>
  26. HttpResponse.json({
  27. id: 1,
  28. provider: 'github',
  29. repository_url: 'https://github.com/test/repo',
  30. branch: 'main',
  31. enabled: true,
  32. schedule_enabled: false,
  33. schedule_type: 'daily',
  34. schedule_time: '02:00',
  35. backup_kprofiles: true,
  36. backup_cloud_profiles: false,
  37. backup_spools: true,
  38. backup_archives: true,
  39. backup_settings: true,
  40. last_backup_at: null,
  41. last_backup_status: null,
  42. }),
  43. ),
  44. http.get('*/api/v1/github-backup/status', () =>
  45. HttpResponse.json({
  46. configured: true,
  47. enabled: true,
  48. is_running: false,
  49. restore_running: false,
  50. progress: null,
  51. last_backup_at: null,
  52. last_backup_status: null,
  53. next_run: null,
  54. }),
  55. ),
  56. http.get('*/api/v1/github-backup/logs', () => HttpResponse.json([])),
  57. );
  58. }
  59. function mockUserWith(permissions: string[]) {
  60. setAuthToken('test-token', 'session');
  61. server.use(
  62. http.get('*/api/v1/auth/status', () =>
  63. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  64. ),
  65. http.get('*/api/v1/auth/me', () =>
  66. HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
  67. ),
  68. );
  69. }
  70. describe('GitHubBackupSettings - github:restore gate', () => {
  71. it('hides the Restore from Git button without the permission', async () => {
  72. mockConfiguredBackup();
  73. mockUserWith(['settings:read', 'settings:update']);
  74. render(<GitHubBackupSettings />);
  75. // Wait for the action row itself, so an absent button is a real absence
  76. // rather than the card simply not having rendered yet.
  77. await waitFor(() => expect(screen.getByRole('button', { name: /Backup Now/i })).toBeInTheDocument());
  78. expect(screen.queryByRole('button', { name: /Restore from Git/i })).not.toBeInTheDocument();
  79. });
  80. it('shows it when the user has github:restore', async () => {
  81. mockConfiguredBackup();
  82. mockUserWith(['settings:read', 'github:restore']);
  83. render(<GitHubBackupSettings />);
  84. await waitFor(() =>
  85. expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
  86. );
  87. });
  88. it('shows it when auth is disabled entirely', async () => {
  89. // hasPermission returns true with auth off, and it must stay that way -
  90. // a single-user instance has no permissions to grant.
  91. mockConfiguredBackup();
  92. server.use(
  93. http.get('*/api/v1/auth/status', () =>
  94. HttpResponse.json({ auth_enabled: false, requires_setup: false }),
  95. ),
  96. );
  97. render(<GitHubBackupSettings />);
  98. await waitFor(() =>
  99. expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
  100. );
  101. });
  102. });
  103. describe('GitHubBackupSettings - a full backup is admin-only', () => {
  104. // It holds every password hash and 2FA secret, and restoring one replaces
  105. // the users: the server refuses both to a non-administrator.
  106. it('offers a non-admin neither the download nor the restore of a full backup', async () => {
  107. mockConfiguredBackup();
  108. mockUserWith(['settings:read', 'settings:backup', 'settings:restore']);
  109. render(<GitHubBackupSettings />);
  110. const download = await screen.findByRole('button', { name: /^Download$/i });
  111. expect(download).toBeDisabled();
  112. expect(screen.getByRole('button', { name: /^Restore$/i })).toBeDisabled();
  113. expect(screen.getAllByText('Only an administrator can change this.').length).toBeGreaterThan(0);
  114. });
  115. it('shows a non-admin the backup folder read-only', async () => {
  116. // Pointed into a folder the web server hands out, a full backup could be
  117. // fetched without signing in.
  118. mockConfiguredBackup();
  119. mockUserWith(['settings:read', 'settings:update', 'settings:backup']);
  120. server.use(
  121. http.get('*/api/v1/local-backup/status', () =>
  122. HttpResponse.json({
  123. enabled: true, schedule: 'daily', time: '03:00', retention: 5, path: '', default_path: '/data/backups',
  124. is_running: false, last_backup_at: null, last_status: null, last_message: null, next_run: null, timezone: 'UTC',
  125. }),
  126. ),
  127. http.get('*/api/v1/local-backup/backups', () => HttpResponse.json([])),
  128. http.get('*/api/v1/local-backup/path-check', () => HttpResponse.json({ ok: true })),
  129. );
  130. render(<GitHubBackupSettings />);
  131. const label = await screen.findByText('Output Path');
  132. const input = label.parentElement?.querySelector('input') as HTMLInputElement;
  133. expect(input).toBeDisabled();
  134. });
  135. it('keeps them for an admin', async () => {
  136. mockConfiguredBackup();
  137. setAuthToken('test-token', 'session');
  138. server.use(
  139. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  140. http.get('*/api/v1/auth/me', () => HttpResponse.json({ id: 1, username: 'admin', is_admin: true, permissions: [] })),
  141. );
  142. render(<GitHubBackupSettings />);
  143. await waitFor(() => expect(screen.getByRole('button', { name: /^Download$/i })).toBeEnabled());
  144. expect(screen.getByRole('button', { name: /^Restore$/i })).toBeEnabled();
  145. });
  146. });