ConnectAuthorizePage.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. /**
  2. * "Sign in with Bambuddy" consent page.
  3. *
  4. * The assertions that carry weight: the page never redirects before the
  5. * backend has vouched for the callback URL, and when it does redirect it uses
  6. * the callback the backend returned, with the code and the app's state.
  7. */
  8. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  9. import { render, screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { MemoryRouter } from 'react-router-dom';
  12. import { api, ApiError } from '../../api/client';
  13. const mockUseAuth = { authEnabled: true, loading: false };
  14. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  15. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  16. return { ...actual, useAuth: () => mockUseAuth };
  17. });
  18. import { ConnectAuthorizePage } from '../../pages/ConnectAuthorizePage';
  19. const CALLBACK = 'http://orders.local:8090/auth/callback';
  20. const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
  21. function query(overrides: Record<string, string> = {}) {
  22. const params = new URLSearchParams({
  23. client_id: 'bba_123',
  24. redirect_uri: CALLBACK,
  25. state: 'st4te',
  26. code_challenge: CHALLENGE,
  27. code_challenge_method: 'S256',
  28. ...overrides,
  29. });
  30. return `?${params.toString()}`;
  31. }
  32. function renderAt(search: string) {
  33. return render(
  34. <MemoryRouter initialEntries={[`/connect/authorize${search}`]}>
  35. <ConnectAuthorizePage />
  36. </MemoryRouter>,
  37. );
  38. }
  39. let replace: ReturnType<typeof vi.fn>;
  40. const realLocation = window.location;
  41. beforeEach(() => {
  42. mockUseAuth.authEnabled = true;
  43. replace = vi.fn();
  44. Object.defineProperty(window, 'location', {
  45. configurable: true,
  46. writable: true,
  47. value: { ...realLocation, href: 'http://localhost:3000/connect/authorize', replace },
  48. });
  49. });
  50. afterEach(() => {
  51. Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation });
  52. vi.restoreAllMocks();
  53. });
  54. describe('ConnectAuthorizePage', () => {
  55. it('asks for consent, then returns to the registered callback with code and state', async () => {
  56. vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
  57. app_name: 'Order Desk',
  58. username: 'martin',
  59. already_granted: false,
  60. });
  61. const authorize = vi
  62. .spyOn(api, 'connectAuthorize')
  63. .mockResolvedValue({ code: 'c0de', redirect_uri: CALLBACK });
  64. renderAt(query());
  65. expect(await screen.findByText('Order Desk wants to sign you in')).toBeInTheDocument();
  66. expect(screen.getByText('Signed in to Bambuddy as martin')).toBeInTheDocument();
  67. expect(replace).not.toHaveBeenCalled();
  68. await userEvent.click(screen.getByRole('button', { name: 'Allow' }));
  69. expect(authorize).toHaveBeenCalledWith({
  70. client_id: 'bba_123',
  71. redirect_uri: CALLBACK,
  72. code_challenge: CHALLENGE,
  73. code_challenge_method: 'S256',
  74. });
  75. await waitFor(() => expect(replace).toHaveBeenCalledWith(`${CALLBACK}?code=c0de&state=st4te`));
  76. });
  77. it('skips the consent screen for an app the user already allowed', async () => {
  78. vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
  79. app_name: 'Order Desk',
  80. username: 'martin',
  81. already_granted: true,
  82. });
  83. vi.spyOn(api, 'connectAuthorize').mockResolvedValue({ code: 'c0de', redirect_uri: CALLBACK });
  84. renderAt(query());
  85. await waitFor(() => expect(replace).toHaveBeenCalledWith(`${CALLBACK}?code=c0de&state=st4te`));
  86. expect(screen.queryByRole('button', { name: 'Allow' })).not.toBeInTheDocument();
  87. });
  88. it('cancel returns access_denied to the app', async () => {
  89. vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
  90. app_name: 'Order Desk',
  91. username: 'martin',
  92. already_granted: false,
  93. });
  94. const authorize = vi.spyOn(api, 'connectAuthorize');
  95. renderAt(query());
  96. await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
  97. expect(replace).toHaveBeenCalledWith(`${CALLBACK}?error=access_denied&state=st4te`);
  98. expect(authorize).not.toHaveBeenCalled();
  99. });
  100. it('shows an error and never redirects when the backend rejects the request', async () => {
  101. vi.spyOn(api, 'getConnectAuthorizeInfo').mockRejectedValue(new ApiError('bad', 400));
  102. renderAt(query({ redirect_uri: 'http://evil.example/steal' }));
  103. expect(await screen.findByText("Can't sign you in")).toBeInTheDocument();
  104. expect(replace).not.toHaveBeenCalled();
  105. });
  106. it('rejects a request without PKCE before asking the backend', async () => {
  107. const info = vi.spyOn(api, 'getConnectAuthorizeInfo');
  108. renderAt(query({ code_challenge_method: 'plain' }));
  109. expect(await screen.findByText("Can't sign you in")).toBeInTheDocument();
  110. expect(info).not.toHaveBeenCalled();
  111. expect(replace).not.toHaveBeenCalled();
  112. });
  113. it('explains that sign-in is unavailable while authentication is off', async () => {
  114. mockUseAuth.authEnabled = false;
  115. const info = vi.spyOn(api, 'getConnectAuthorizeInfo');
  116. renderAt(query());
  117. expect(await screen.findByText(/Bambuddy authentication is turned off/)).toBeInTheDocument();
  118. expect(info).not.toHaveBeenCalled();
  119. expect(replace).not.toHaveBeenCalled();
  120. });
  121. });