ExternalLinkPageNavigate.test.tsx 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. /**
  2. * The framed app may ask Bambuddy to open one of its own pages in place
  3. * (Bambuddy Orders' "In Bambuddy" link): only from the frame, only from the
  4. * link's origin, only for a path inside Bambuddy.
  5. */
  6. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  7. import { screen } from '@testing-library/react';
  8. import { Route, Routes, useLocation } from 'react-router-dom';
  9. import { render } from '../utils';
  10. import { api } from '../../api/client';
  11. import { ExternalLinkPage } from '../../pages/ExternalLinkPage';
  12. const ORIGIN = 'http://orders.local:8090';
  13. const LINK = {
  14. id: 5,
  15. name: 'Orders',
  16. url: `${ORIGIN}/`,
  17. icon: 'shopping-cart',
  18. open_in_new_tab: false,
  19. custom_icon: null,
  20. sort_order: 0,
  21. created_at: '2026-09-27T00:00:00Z',
  22. updated_at: '2026-09-27T00:00:00Z',
  23. };
  24. function Where() {
  25. const location = useLocation();
  26. return <p data-testid="where">{location.pathname + location.search}</p>;
  27. }
  28. async function renderFrame() {
  29. window.history.pushState({}, '', '/external/5');
  30. render(
  31. <Routes>
  32. <Route path="/external/:id" element={<ExternalLinkPage />} />
  33. <Route path="*" element={<Where />} />
  34. </Routes>,
  35. );
  36. return (await screen.findByTitle('Orders')) as HTMLIFrameElement;
  37. }
  38. function ask(frame: HTMLIFrameElement | Window, path: unknown, origin = ORIGIN) {
  39. const source = frame instanceof HTMLIFrameElement ? frame.contentWindow : frame;
  40. window.dispatchEvent(new MessageEvent('message', { data: { type: 'bambuddy:navigate', path }, origin, source }));
  41. }
  42. beforeEach(() => {
  43. vi.spyOn(api, 'getExternalLink').mockResolvedValue(LINK);
  44. });
  45. afterEach(() => {
  46. vi.restoreAllMocks();
  47. });
  48. describe('ExternalLinkPage navigate requests', () => {
  49. it('opens the asked-for Bambuddy page', async () => {
  50. const frame = await renderFrame();
  51. ask(frame, '/queue?batch=12');
  52. expect(await screen.findByTestId('where')).toHaveTextContent('/queue?batch=12');
  53. });
  54. it.each([
  55. ['another site', 'https://evil.example/login'],
  56. ['a protocol-relative address', '//evil.example/login'],
  57. ['a backslash trick', '/\\evil.example'],
  58. ['a script', 'javascript:alert(1)'],
  59. ['a relative path', 'queue'],
  60. ['no string', 42],
  61. ])('ignores %s', async (_name, path) => {
  62. const frame = await renderFrame();
  63. ask(frame, path);
  64. await new Promise((r) => setTimeout(r, 20));
  65. expect(screen.getByTitle('Orders')).toBeInTheDocument();
  66. });
  67. it('ignores requests from other windows or origins', async () => {
  68. const frame = await renderFrame();
  69. ask(window, '/queue');
  70. ask(frame, '/queue', 'http://evil.example');
  71. await new Promise((r) => setTimeout(r, 20));
  72. expect(screen.getByTitle('Orders')).toBeInTheDocument();
  73. expect(screen.queryByTestId('where')).not.toBeInTheDocument();
  74. });
  75. });