OrcaCloudView.test.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * Tests for OrcaCloudView component — covers the four UI phases of the
  3. * paste-based PKCE handshake: disconnected, awaiting-paste, connected,
  4. * and disconnect.
  5. */
  6. import { describe, it, expect, beforeEach, vi } from 'vitest';
  7. import { screen, waitFor, fireEvent } from '@testing-library/react';
  8. import { http, HttpResponse } from 'msw';
  9. import { server } from '../mocks/server';
  10. import { render } from '../utils';
  11. import { OrcaCloudView } from '../../components/OrcaCloudView';
  12. // JSDOM doesn't implement window.open; the connect flow opens the auth URL
  13. // in a new tab so we stub it to capture the call.
  14. beforeEach(() => {
  15. vi.stubGlobal('open', vi.fn());
  16. });
  17. const noProfilesResponse = { profiles: [] };
  18. describe('OrcaCloudView', () => {
  19. it('shows all four sign-in options when not connected', async () => {
  20. server.use(
  21. http.get('/api/v1/orca-cloud/status', () =>
  22. HttpResponse.json({ connected: false, email: null, user_id: null }),
  23. ),
  24. );
  25. render(<OrcaCloudView />);
  26. await waitFor(() => {
  27. expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
  28. });
  29. expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
  30. expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
  31. expect(screen.getByRole('button', { name: /Sign in with GitHub/i })).toBeInTheDocument();
  32. expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
  33. });
  34. it('passes the selected OAuth provider to auth/start', async () => {
  35. let receivedProvider: string | undefined;
  36. server.use(
  37. http.get('/api/v1/orca-cloud/status', () =>
  38. HttpResponse.json({ connected: false, email: null, user_id: null }),
  39. ),
  40. http.post('/api/v1/orca-cloud/auth/start', async ({ request }) => {
  41. const body = (await request.json()) as { provider?: string };
  42. receivedProvider = body.provider;
  43. return HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' });
  44. }),
  45. );
  46. render(<OrcaCloudView />);
  47. await waitFor(() => {
  48. expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
  49. });
  50. fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i }));
  51. await waitFor(() => {
  52. expect(receivedProvider).toBe('apple');
  53. });
  54. expect(window.open).toHaveBeenCalledWith(
  55. 'https://auth.orcaslicer.com/auth/v1/authorize?test=1',
  56. '_blank',
  57. 'noopener,noreferrer',
  58. );
  59. });
  60. it('connects via email and password without the paste flow', async () => {
  61. let connected = false;
  62. let receivedCreds: { email?: string; password?: string } = {};
  63. server.use(
  64. http.get('/api/v1/orca-cloud/status', () =>
  65. HttpResponse.json(
  66. connected
  67. ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
  68. : { connected: false, email: null, user_id: null },
  69. ),
  70. ),
  71. http.post('/api/v1/orca-cloud/auth/password', async ({ request }) => {
  72. receivedCreds = (await request.json()) as { email?: string; password?: string };
  73. connected = true;
  74. return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
  75. }),
  76. http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
  77. );
  78. render(<OrcaCloudView />);
  79. await waitFor(() => {
  80. expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
  81. });
  82. fireEvent.click(screen.getByRole('button', { name: /Sign in with email and password/i }));
  83. // The password form replaces the provider picker.
  84. await waitFor(() => {
  85. expect(screen.getByLabelText(/^Email$/i)).toBeInTheDocument();
  86. });
  87. fireEvent.change(screen.getByLabelText(/^Email$/i), { target: { value: 'martin@example.com' } });
  88. fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'hunter2' } });
  89. // Click the submit button inside the form (not the picker's email button).
  90. const submitButtons = screen.getAllByRole('button', { name: /^Sign in$/i });
  91. fireEvent.click(submitButtons[submitButtons.length - 1]);
  92. await waitFor(() => {
  93. expect(screen.getByText('martin@example.com')).toBeInTheDocument();
  94. });
  95. expect(receivedCreds).toEqual({ email: 'martin@example.com', password: 'hunter2' });
  96. });
  97. it('rejects a URL without a code parameter with a client-side error', async () => {
  98. server.use(
  99. http.get('/api/v1/orca-cloud/status', () =>
  100. HttpResponse.json({ connected: false, email: null, user_id: null }),
  101. ),
  102. http.post('/api/v1/orca-cloud/auth/start', () =>
  103. HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
  104. ),
  105. );
  106. render(<OrcaCloudView />);
  107. await waitFor(() => {
  108. expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
  109. });
  110. fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
  111. await waitFor(() => {
  112. expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
  113. });
  114. const textarea = screen.getByPlaceholderText(/http:\/\/localhost:41172/i);
  115. // Paste something with no ?code= — the client-side guard should fire
  116. // before we hit the server.
  117. fireEvent.change(textarea, { target: { value: 'http://localhost:41172/callback?error=denied' } });
  118. fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
  119. await waitFor(() => {
  120. expect(screen.getByText(/does not look like an Orca Cloud callback/i)).toBeInTheDocument();
  121. });
  122. });
  123. it('shows the connected state with the email after a successful paste', async () => {
  124. // Start disconnected; after a successful finish the status query refetches
  125. // and returns connected. MSW lets us swap handlers mid-test.
  126. let connected = false;
  127. server.use(
  128. http.get('/api/v1/orca-cloud/status', () => {
  129. return HttpResponse.json(
  130. connected
  131. ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
  132. : { connected: false, email: null, user_id: null },
  133. );
  134. }),
  135. http.post('/api/v1/orca-cloud/auth/start', () =>
  136. HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
  137. ),
  138. http.post('/api/v1/orca-cloud/auth/finish', () => {
  139. connected = true;
  140. return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
  141. }),
  142. http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
  143. );
  144. render(<OrcaCloudView />);
  145. await waitFor(() => {
  146. expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
  147. });
  148. fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
  149. await waitFor(() => {
  150. expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
  151. });
  152. fireEvent.change(screen.getByPlaceholderText(/http:\/\/localhost:41172/i), {
  153. target: { value: 'http://localhost:41172/callback?code=ABC&state=XYZ' },
  154. });
  155. fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
  156. // After the finish call resolves, the status query is invalidated and
  157. // refetches connected=true → the connection banner appears with the email.
  158. await waitFor(() => {
  159. expect(screen.getByText('martin@example.com')).toBeInTheDocument();
  160. });
  161. expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
  162. });
  163. it('clears the connection on Disconnect', async () => {
  164. let connected = true;
  165. server.use(
  166. http.get('/api/v1/orca-cloud/status', () =>
  167. HttpResponse.json(
  168. connected
  169. ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
  170. : { connected: false, email: null, user_id: null },
  171. ),
  172. ),
  173. http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
  174. http.post('/api/v1/orca-cloud/logout', () => {
  175. connected = false;
  176. return HttpResponse.json({ success: true });
  177. }),
  178. );
  179. render(<OrcaCloudView />);
  180. await waitFor(() => {
  181. expect(screen.getByText('martin@example.com')).toBeInTheDocument();
  182. });
  183. fireEvent.click(screen.getByRole('button', { name: /Disconnect/i }));
  184. await waitFor(() => {
  185. expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
  186. });
  187. });
  188. });