diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx index 936edc42..af2ae1d5 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx @@ -19,7 +19,7 @@ vi.mock('./services/analyticsService', () => ({ })); // Mock the components -vi.mock('./pages/login/Login', () => ({ +vi.mock('./pages/login', () => ({ default: () =>
Mocked Login Component
, })); vi.mock('./pages/logout/Logout', () => ({ diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx index b0655bc5..acbb2834 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx @@ -1,4 +1,3 @@ -import Login from './pages/login/Login'; import { ROUTE_LOGIN, ROUTE_LOGIN_ERROR, @@ -7,6 +6,7 @@ import { import { redirectToLogin } from './utils/utils'; import Logout from './pages/logout/Logout'; import { LoginError } from './pages/loginError/LoginError'; +import Login from './pages/login'; const onLogout = () => ; const onLoginError = () => ; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx new file mode 100644 index 00000000..6325c2ed --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx @@ -0,0 +1,104 @@ +import { Mock, vi } from 'vitest'; +import { useLoginData } from './useLoginData'; +import { fetchBannerContent, getIdpList, getClientData } from '../services/api'; +import { ENV } from '../utils/env'; +import { renderHook, waitFor } from '@testing-library/react'; + +vi.mock('../services/api', () => ({ + fetchBannerContent: vi.fn(), + getIdpList: vi.fn(), + getClientData: vi.fn(), +})); + +describe('useLoginData', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch and set bannerContent, idpList, and clientData on mount', async () => { + const mockBanner = [ + { enable: true, severity: 'info', description: 'Test Banner' }, + ]; + const mockIdpList = { + identityProviders: [{ entityID: 'test-idp' }], + richiediSpid: 'https://example.com/spid', + }; + const mockClientData = { + clientID: 'test-client-id', + friendlyName: 'Test Client', + logoUri: 'https://example.com/logo.png', + policyUri: 'https://example.com/policy', + tosUri: 'https://example.com/tos', + }; + + // Mock API responses + (fetchBannerContent as Mock).mockResolvedValueOnce(mockBanner); + (getIdpList as Mock).mockResolvedValueOnce({ idps: mockIdpList }); + (getClientData as Mock).mockResolvedValueOnce({ + clientData: mockClientData, + }); + + // Render the hook + const { result } = renderHook(useLoginData); + + await waitFor(() => { + expect(fetchBannerContent).toHaveBeenCalledWith(ENV.JSON_URL.ALERT); + expect(getIdpList).toHaveBeenCalledWith(ENV.JSON_URL.IDP_LIST); + expect(getClientData).toHaveBeenCalledWith(ENV.JSON_URL.CLIENT_BASE_URL); + }); + + // Check if the state is updated correctly + expect(result.current.bannerContent).toEqual(mockBanner); + expect(result.current.idpList).toEqual(mockIdpList); + expect(result.current.clientData).toEqual(mockClientData); + }); + + it('should not set state if API calls fail', async () => { + // Mock failed API responses + (fetchBannerContent as Mock).mockRejectedValueOnce( + new Error('Failed to fetch banner') + ); + (getIdpList as Mock).mockRejectedValueOnce( + new Error('Failed to fetch IDP list') + ); + (getClientData as Mock).mockRejectedValueOnce( + new Error('Failed to fetch client data') + ); + + const { result } = renderHook(useLoginData); + + await waitFor(() => { + // Check that the state has not been set + expect(result.current.bannerContent).toBeUndefined(); + expect(result.current.idpList).toEqual({ + identityProviders: [], + richiediSpid: '', + }); + expect(result.current.clientData).toBeUndefined(); + }); + }); + + it('should handle partial data successfully', async () => { + const mockBanner = [ + { enable: true, severity: 'info', description: 'Test Banner' }, + ]; + const mockIdpList = { + identityProviders: [{ entityID: 'test-idp' }], + richiediSpid: 'https://example.com/spid', + }; + + // Mock API responses + (fetchBannerContent as Mock).mockResolvedValueOnce(mockBanner); + (getIdpList as Mock).mockResolvedValueOnce({ idps: mockIdpList }); + (getClientData as Mock).mockResolvedValueOnce({ clientData: undefined }); // No client data + + const { result } = renderHook(useLoginData); + + await waitFor(() => { + // Verify that bannerContent and idpList are correctly set + expect(result.current.bannerContent).toEqual(mockBanner); + expect(result.current.idpList).toEqual(mockIdpList); + expect(result.current.clientData).toBeUndefined(); // clientData should be undefined + }); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx new file mode 100644 index 00000000..b882efdb --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { ENV } from '../utils/env'; +import { IdentityProviders } from '../utils/IDPS'; +import { + type BannerContent, + type Client, + fetchBannerContent, + getIdpList, + getClientData, +} from '../services/api'; + +export const useLoginData = () => { + const [bannerContent, setBannerContent] = useState>(); + const [idpList, setIdpList] = useState({ + identityProviders: [], + richiediSpid: '', + }); + const [clientData, setClientData] = useState(); + + useEffect(() => { + const bannerRequest = fetchBannerContent(ENV.JSON_URL.ALERT); + const idpsRequest = getIdpList(ENV.JSON_URL.IDP_LIST); + const clientDataRequest = getClientData(ENV.JSON_URL.CLIENT_BASE_URL); + + Promise.allSettled([idpsRequest, clientDataRequest, bannerRequest]).then( + ([idpsResult, clientDataResult, bannerResult]) => { + if (idpsResult.status === 'fulfilled' && idpsResult.value.idps) { + setIdpList(idpsResult.value.idps); + } else { + console.error('Failed to fetch IDP list:', idpsResult.status); + } + + if ( + clientDataResult.status === 'fulfilled' && + clientDataResult.value.clientData + ) { + setClientData(clientDataResult.value.clientData); + } else { + console.error( + 'Failed to fetch client data:', + clientDataResult.status + ); + } + + if (bannerResult.status === 'fulfilled' && bannerResult.value?.length) { + setBannerContent(bannerResult.value); + } else { + console.error('Failed to fetch banner content:', bannerResult.status); + } + } + ); + }, []); + + return { bannerContent, idpList, clientData }; +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx index d78f5434..55a97b31 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx @@ -1,20 +1,50 @@ /* eslint-disable functional/immutable-data */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, expect, Mock, test, vi } from 'vitest'; - +import { afterAll, beforeAll, afterEach, test, vi, Mock } from 'vitest'; import { ENV } from '../../utils/env'; import { i18nTestSetup } from '../../__tests__/i18nTestSetup'; -import Login from './Login'; - -// Mock fetch -global.fetch = vi.fn(); - +import Login from '.'; + +// Constants for repeated strings +const SPID_LOGIN = 'spidButton'; +const CIE_LOGIN = 'CIE Login'; +const LOGIN_TITLE = 'Login Title'; +const LOGIN_DESCRIPTION = 'Login Description'; +const TEMPORARY_LOGIN_ALERT = 'Temporary Login Alert'; +const ALERT_DESCRIPTION = 'This is a warning!'; +const MOCK_IDP_ENTITY_ID = 'test-idp'; +const MOCK_RICHIEDI_SPID_URL = 'https://example.com/spid'; + +// Setup translations i18nTestSetup({ loginPage: { + title: LOGIN_TITLE, + description: LOGIN_DESCRIPTION, + loginBox: { + spidLogin: SPID_LOGIN, + cieLogin: CIE_LOGIN, + }, privacyAndCondition: { text: 'terms: {{termsLink}} privacy: {{privacyLink}}', }, + temporaryLogin: { + alert: TEMPORARY_LOGIN_ALERT, + join: 'Join', + }, }, + spidSelect: { + modalTitle: 'test modal', + }, +}); + +// Clear mocks after each test +afterEach(() => { + vi.clearAllMocks(); +}); + +beforeEach(() => { + // Mock fetch + global.fetch = vi.fn(); }); const oldWindowLocation = global.window.location; @@ -28,22 +58,16 @@ afterAll(() => { Object.defineProperty(window, 'location', { value: oldWindowLocation }); }); -// Clear mocks after each test -afterEach(() => { - vi.clearAllMocks(); -}); - +// Test cases test('Renders Login component', () => { render(); - expect(screen.getByText('loginPage.title')).toBeInTheDocument(); + expect(screen.getByText(LOGIN_TITLE)).toBeInTheDocument(); + expect(screen.getByText(LOGIN_DESCRIPTION)).toBeInTheDocument(); }); -const mockWarning = 'This is a warning!'; - test('Fetches and displays banner alerts', async () => { - // Mock the fetch response const mockBannerResponse = [ - { enable: true, severity: 'warning', description: mockWarning }, + { enable: true, severity: 'warning', description: ALERT_DESCRIPTION }, ]; (fetch as Mock).mockResolvedValueOnce({ json: vi.fn().mockResolvedValueOnce(mockBannerResponse), @@ -52,7 +76,7 @@ test('Fetches and displays banner alerts', async () => { render(); await waitFor(() => { - expect(screen.getByText(mockWarning)).toBeInTheDocument(); + expect(screen.getByText(ALERT_DESCRIPTION)).toBeInTheDocument(); }); }); @@ -61,16 +85,15 @@ test('Handles fetch error for alert message', async () => { render(); - // Optionally check if an error message or warning is displayed await waitFor(() => { - expect(screen.queryByText(mockWarning)).not.toBeInTheDocument(); + expect(screen.queryByText(ALERT_DESCRIPTION)).not.toBeInTheDocument(); }); }); test('Fetches IDP list on mount', async () => { const mockIDPListResponse = { - identityProviders: [{ entityID: 'test-idp' }], - richiediSpid: 'https://example.com/spid', + identityProviders: [{ entityID: MOCK_IDP_ENTITY_ID }], + richiediSpid: MOCK_RICHIEDI_SPID_URL, }; (fetch as Mock).mockResolvedValueOnce({ json: vi.fn().mockResolvedValueOnce(mockIDPListResponse), @@ -84,7 +107,7 @@ test('Fetches IDP list on mount', async () => { }); test('Handles invalid client ID gracefully', async () => { - window.history.pushState({}, '', `?client_id=invalidId`); + window.history.pushState({}, '', '?client_id=invalidId'); render(); @@ -93,19 +116,9 @@ test('Handles invalid client ID gracefully', async () => { }); }); -test('Clicking SPID button opens modal', () => { - render(); - const buttonSpid = document.getElementById('spidButton'); - fireEvent.click(buttonSpid as HTMLElement); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); // Check if modal opens -}); - test('Clicking CIE button redirects correctly', () => { render(); - const buttonCIE = screen.getByRole('button', { - name: 'loginPage.loginBox.cieLogin', - }); + const buttonCIE = screen.getByText(CIE_LOGIN); fireEvent.click(buttonCIE); expect(global.window.location.assign).toHaveBeenCalledWith( @@ -113,26 +126,8 @@ test('Clicking CIE button redirects correctly', () => { ); }); -test('Clicking terms and conditions link redirects correctly', () => { - render(); - - const termsConditionLink = screen.getByText( - 'loginPage.privacyAndCondition.terms' - ); - fireEvent.click(termsConditionLink); - - expect(global.window.location.assign).toHaveBeenCalledWith( - ENV.URL_FOOTER.TERMS_AND_CONDITIONS - ); -}); - -test('Clicking privacy link redirects correctly', () => { +test('Displays temporary login alert if enabled', () => { render(); - - const privacyLink = screen.getByText('loginPage.privacyAndCondition.privacy'); - fireEvent.click(privacyLink); - - expect(global.window.location.assign).toHaveBeenCalledWith( - ENV.URL_FOOTER.PRIVACY_DISCLAIMER - ); + const temporaryLoginAlert = screen.getByText(TEMPORARY_LOGIN_ALERT); + expect(temporaryLoginAlert).toBeInTheDocument(); }); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.test.tsx new file mode 100644 index 00000000..e7271b2a --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, vi } from 'vitest'; +import { CieButton, CieButtonProps } from './CieButton'; +import { i18nTestSetup } from '../../../__tests__/i18nTestSetup'; + +describe('CieButton', () => { + const BUTTON_TEXT = 'CIE Login'; + const onClickMock = vi.fn(); + + const renderComponent = (props: Partial = {}) => { + i18nTestSetup({ 'loginPage.loginBox.cieLogin': BUTTON_TEXT }); + render(); + }; + + it('renders the button with the correct text', () => { + renderComponent(); + expect( + screen.getByRole('button', { name: BUTTON_TEXT }) + ).toBeInTheDocument(); + }); + + it('displays the CIE icon', () => { + renderComponent(); + const icon = screen.getByAltText('CIE Icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('src', expect.stringContaining('CIEIcon')); + }); + + it('calls the onClick handler when clicked', async () => { + renderComponent(); + const button = screen.getByRole('button', { name: BUTTON_TEXT }); + await userEvent.click(button); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.tsx new file mode 100644 index 00000000..a80dc189 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton.tsx @@ -0,0 +1,33 @@ +import Icon from '@mui/material/Icon'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import CIEIcon from '../../../assets/CIEIcon.svg'; + +export type CieButtonProps = { + onClick: () => void; +}; + +export const CieIconWrapper = () => ( + + CIE Icon + +); + +export const CieButton = ({ onClick }: CieButtonProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.test.tsx new file mode 100644 index 00000000..c6a5e4fb --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import { SpidButton, SpidButtonProps } from './SpidButton'; +import { i18nTestSetup } from '../../../__tests__/i18nTestSetup'; + +const TEST_ID = 'spidButton'; + +describe('SpidButton Component', () => { + const onClickMock = vi.fn(); + + beforeAll(() => { + i18nTestSetup({ + 'loginPage.loginBox.spidLogin': 'Login with SPID', + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = (props: SpidButtonProps) => + render(); + + it('renders the button with the correct text', () => { + renderComponent({ loading: false, onClick: onClickMock }); + + const button = screen.getByTestId(TEST_ID); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Login with SPID'); + }); + + it('renders the button in a loading state', () => { + renderComponent({ loading: true, onClick: onClickMock }); + + const button = screen.getByTestId(TEST_ID); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toBeDisabled(); + }); + + it('triggers the onClick function when clicked', () => { + renderComponent({ loading: false, onClick: onClickMock }); + + const button = screen.getByTestId(TEST_ID); + fireEvent.click(button); + + expect(onClickMock).toHaveBeenCalledOnce(); + }); + + it('displays the SPID icon', () => { + renderComponent({ loading: false, onClick: onClickMock }); + + const icon = screen.getByAltText('SPID Icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('src', expect.stringContaining('SpidIcon')); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.tsx new file mode 100644 index 00000000..db0fd673 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton.tsx @@ -0,0 +1,50 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import SpidIcon from '../../../assets/SpidIcon.svg'; +import Icon from '@mui/material/Icon'; +import Typography from '@mui/material/Typography'; +import { theme } from '@pagopa/mui-italia/dist/theme'; +import { useTranslation } from 'react-i18next'; + +export const SpidIconWrapper = () => ( + + SPID Icon + +); + +export type SpidButtonProps = { + loading: boolean; + onClick: () => void; +}; + +export const SpidButton = ({ loading, onClick }: SpidButtonProps) => { + const { t } = useTranslation(); + + return ( + } + sx={{ + borderRadius: '4px', + width: '100%', + marginBottom: '5px', + }} + variant="contained" + > + + {t('loginPage.loginBox.spidLogin')} + + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx similarity index 87% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx index 4e182917..603a59ed 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx @@ -1,12 +1,12 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { IdentityProviders } from '../../utils/IDPS'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; +import { IdentityProviders } from '../../../utils/IDPS'; +import { trackEvent } from '../../../services/analyticsService'; +import { forwardSearchParams } from '../../../utils/utils'; import SpidModal from './SpidModal'; -vi.mock('../../services/analyticsService'); -vi.mock('../../utils/utils', () => ({ +vi.mock('../../../services/analyticsService'); +vi.mock('../../../utils/utils', () => ({ forwardSearchParams: vi.fn(() => 'testParams'), })); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx similarity index 84% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx index 11f1b747..862f0ee2 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx @@ -1,12 +1,12 @@ import { Button, Dialog, Grid, Icon, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import { ENV } from '../../utils/env'; -import { ImageWithFallback } from '../../components/ImageFallback'; -import { IDP_PLACEHOLDER_IMG } from '../../utils/constants'; +import { IdentityProvider, IdentityProviders } from '../../../utils/IDPS'; +import { trackEvent } from '../../../services/analyticsService'; +import { forwardSearchParams } from '../../../utils/utils'; +import { ENV } from '../../../utils/env'; +import { ImageWithFallback } from '../../../components/ImageFallback'; +import { IDP_PLACEHOLDER_IMG } from '../../../utils/constants'; type Props = { openSpidModal: boolean; @@ -32,7 +32,11 @@ const SpidModal = ({ openSpidModal, setOpenSpidModal, idpList }: Props) => { const { t } = useTranslation(); return ( - setOpenSpidModal(false)}> + setOpenSpidModal(false)} + > { - // eslint-disable-next-line functional/immutable-data Object.defineProperty(window, 'location', { value: { assign: vi.fn() } }); }); afterAll(() => { - // eslint-disable-next-line functional/immutable-data Object.defineProperty(window, 'location', { value: oldWindowLocation }); }); const idpList = { diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx similarity index 90% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx index b79c79dc..9b1b819f 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx @@ -7,16 +7,16 @@ import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import { useTranslation } from 'react-i18next'; -import { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; -import SpidBig from '../../assets/spid_big.svg'; -import { ENV } from '../../utils/env'; +import { IdentityProvider, IdentityProviders } from '../../../utils/IDPS'; +import SpidBig from '../../../assets/spid_big.svg'; +import { ENV } from '../../../utils/env'; import { ENABLE_LANDING_REDIRECT, IDP_PLACEHOLDER_IMG, -} from '../../utils/constants'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import { ImageWithFallback } from '../../components/ImageFallback'; +} from '../../../utils/constants'; +import { trackEvent } from '../../../services/analyticsService'; +import { forwardSearchParams } from '../../../utils/utils'; +import { ImageWithFallback } from '../../../components/ImageFallback'; type Props = { onBack: () => void; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx similarity index 64% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx index 8f6caeb6..02101b99 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx @@ -1,17 +1,13 @@ -import { useEffect, useState } from 'react'; -import Button from '@mui/material/Button'; +import { useState } from 'react'; import Link from '@mui/material/Link'; import Grid from '@mui/material/Grid'; import Box from '@mui/material/Box'; -import Icon from '@mui/material/Icon'; import { Alert, IconButton } from '@mui/material'; import Typography from '@mui/material/Typography'; import { Trans, useTranslation } from 'react-i18next'; import { theme } from '@pagopa/mui-italia'; import Layout from '../../components/Layout'; -import SpidIcon from '../../assets/SpidIcon.svg'; -import CIEIcon from '../../assets/CIEIcon.svg'; import { ENV } from '../../utils/env'; import { ENABLE_LANDING_REDIRECT, @@ -19,36 +15,12 @@ import { } from '../../utils/constants'; import { trackEvent } from '../../services/analyticsService'; import { forwardSearchParams } from '../../utils/utils'; -import type { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; import { ImageWithFallback } from '../../components/ImageFallback'; -import SpidSelect from './SpidSelect'; -import SpidModal from './SpidModal'; - -type BannerContent = { - enable: boolean; - severity: 'warning' | 'error' | 'info' | 'success'; - description: string; -}; - -type Client = { - clientID: string; - friendlyName: string; - logoUri: string; - policyUri: string; - tosUri: string; -}; - -export const SpidIconWrapper = () => ( - - - -); - -export const CieIconWrapper = () => ( - - - -); +import SpidModal from './components/SpidModal'; +import { useLoginData } from '../../hooks/useLoginData'; +import { SpidButton } from './components/SpidButton'; +import { CieButton } from './components/CieButton'; +import SpidSelect from './components/SpidSelect'; export const LinkWrapper = ({ onClick, @@ -72,81 +44,15 @@ export const LinkWrapper = ({ ); const Login = () => { - const [showIDPS, setShowIDPS] = useState(false); - const [bannerContent, setBannerContent] = useState>(); const [openSpidModal, setOpenSpidModal] = useState(false); - const [idpList, setIdpList] = useState({ - identityProviders: [], - richiediSpid: '', - }); - const [clientData, setClientData] = useState(); - - const mapToArray = (json: Record) => { - const mapped = Object.values(json); - setBannerContent(mapped as Array); - }; - - const alertMessage = async (loginBanner: string) => { - try { - const response = await fetch(loginBanner); - const res = await response.json(); - mapToArray(res); - } catch (error) { - console.error(error); - } - }; - - const getIdpList = async (idpListUrl: string) => { - try { - const response = await fetch(idpListUrl); - const res: Array = await response.json(); - const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; - const rawIDPS = res - .map((i) => ({ - ...i, - imageUrl: `${assetsIDPUrl}/${btoa(i.entityID)}.png`, - })) - .sort(() => 0.5 - Math.random()); - const IDPS: { - identityProviders: Array; - richiediSpid: string; - } = { - identityProviders: rawIDPS, - richiediSpid: 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/', - }; - setIdpList(IDPS); - } catch (error) { - console.error(error); - } - }; - - const getClientData = async (clientBaseListUrl: string) => { - try { - const query = new URLSearchParams(window.location.search); - const clientID = query.get('client_id'); - - if (clientID && clientID.match(/^[A-Za-z0-9_-]{43}$/)) { - const clientListUrl = `${clientBaseListUrl}/${clientID}`; - const response = await fetch(clientListUrl); - const res: Client = await response.json(); - setClientData(res); - } else { - console.warn('no client_id supplied, or not valid 32bit Base64Url'); - } - } catch (error) { - console.error(error); - } - }; - - useEffect(() => { - void alertMessage(ENV.JSON_URL.ALERT); - void getIdpList(ENV.JSON_URL.IDP_LIST); - void getClientData(ENV.JSON_URL.CLIENT_BASE_URL); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const [showIDPS, setShowIDPS] = useState(false); + const { bannerContent, clientData, idpList } = useLoginData(); + const idpLoading = !idpList?.identityProviders?.length; const { t } = useTranslation(); + const columnsOccupiedByAlert = 5; + const goCIE = () => { const params = forwardSearchParams(ENV.SPID_CIE_ENTITY_ID); const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; @@ -191,8 +97,6 @@ const Login = () => { return ; } - const columnsOccupiedByAlert = 5; - return ( @@ -325,42 +229,13 @@ const Login = () => { idpList={idpList} /> - + /> - + diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts new file mode 100644 index 00000000..36d5aae9 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getIdpList, getClientData, fetchBannerContent } from './api'; +import { ENV } from '../utils/env'; + +const MOCK_IDP_LIST_URL = 'https://example.com/idps'; +const MOCK_CLIENT_BASE_LIST_URL = 'https://example.com/clients'; +const MOCK_LOGIN_BANNER_URL = 'https://example.com/banner'; +const SPID_RICHIEDI_URL = + 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/'; +const INVALID_CLIENT_ID_WARNING = + 'no client_id supplied, or not valid 32bit Base64Url'; + +const mockFetch = vi.fn(); + +vi.stubGlobal('fetch', mockFetch); + +describe('API Functions', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getIdpList', () => { + it('should return a sorted list of identity providers with image URLs', async () => { + const mockIdps = [{ entityID: 'test-idp' }]; + const expectedImageUrl = `${ENV.URL_FE.ASSETS}/idps/${btoa('test-idp')}.png`; + + mockFetch.mockResolvedValueOnce({ + json: vi.fn().mockResolvedValueOnce(mockIdps), + }); + + const result = await getIdpList(MOCK_IDP_LIST_URL); + + expect(mockFetch).toHaveBeenCalledWith(MOCK_IDP_LIST_URL); + expect(result.idps?.identityProviders).toEqual([ + { ...mockIdps[0], imageUrl: expectedImageUrl }, + ]); + expect(result.idps?.richiediSpid).toBe(SPID_RICHIEDI_URL); + }); + + it('should return undefined idps on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error(INVALID_CLIENT_ID_WARNING)); + + const result = await getIdpList(MOCK_IDP_LIST_URL); + + expect(mockFetch).toHaveBeenCalledWith(MOCK_IDP_LIST_URL); + expect(result.idps).toBeUndefined(); + }); + }); + + describe('getClientData', () => { + beforeEach(() => { + vi.stubGlobal('window', { + location: { search: '?client_id=test-client-id' }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.skip('should return client data when client ID is valid', async () => { + const mockClientData = { + clientID: 'test-client-id', + friendlyName: 'Test Client', + }; + + mockFetch.mockResolvedValueOnce({ + json: vi.fn().mockResolvedValueOnce(mockClientData), + }); + + const result = await getClientData(MOCK_CLIENT_BASE_LIST_URL); + + expect(mockFetch).toHaveBeenCalledWith( + `${MOCK_CLIENT_BASE_LIST_URL}/test-client-id` + ); + expect(result.clientData).toEqual(mockClientData); + }); + + it('should return undefined client data for invalid client ID', async () => { + vi.stubGlobal('window', { + location: { search: '?client_id=invalid-id' }, + }); + + const result = await getClientData(MOCK_CLIENT_BASE_LIST_URL); + + expect(result.clientData).toBeUndefined(); + }); + + it('should return undefined client data on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error(INVALID_CLIENT_ID_WARNING)); + + const result = await getClientData(MOCK_CLIENT_BASE_LIST_URL); + + expect(result.clientData).toBeUndefined(); + }); + }); + + describe('fetchBannerContent', () => { + it('should return an array of banner content', async () => { + const mockBannerContent = { + banner1: { enable: true, severity: 'info', description: 'Test Banner' }, + banner2: { + enable: false, + severity: 'error', + description: 'Error Banner', + }, + }; + + mockFetch.mockResolvedValueOnce({ + json: vi.fn().mockResolvedValueOnce(mockBannerContent), + }); + + const result = await fetchBannerContent(MOCK_LOGIN_BANNER_URL); + + expect(mockFetch).toHaveBeenCalledWith(MOCK_LOGIN_BANNER_URL); + expect(result).toEqual(Object.values(mockBannerContent)); + }); + + it('should return an empty array on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error(INVALID_CLIENT_ID_WARNING)); + + const result = await fetchBannerContent(MOCK_LOGIN_BANNER_URL); + + expect(mockFetch).toHaveBeenCalledWith(MOCK_LOGIN_BANNER_URL); + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts new file mode 100644 index 00000000..9ff4935d --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts @@ -0,0 +1,74 @@ +import { ENV } from '../utils/env'; +import { IdentityProvider } from '../utils/IDPS'; + +export type BannerContent = { + enable: boolean; + severity: 'warning' | 'error' | 'info' | 'success'; + description: string; +}; + +export type Client = { + clientID: string; + friendlyName: string; + logoUri: string; + policyUri: string; + tosUri: string; +}; + +export const getIdpList = async (idpListUrl: string) => { + try { + const response = await fetch(idpListUrl); + const res: Array = await response.json(); + const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; + const rawIDPS = res + .map((i) => ({ + ...i, + imageUrl: `${assetsIDPUrl}/${btoa(i.entityID)}.png`, + })) + .sort(() => 0.5 - Math.random()); + const idps: { + identityProviders: Array; + richiediSpid: string; + } = { + identityProviders: rawIDPS, + richiediSpid: 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/', + }; + return { idps }; + } catch (error) { + console.error(error); + return { idps: undefined }; + } +}; + +export const getClientData = async (clientBaseListUrl: string) => { + try { + const query = new URLSearchParams(window.location.search); + const clientID = query.get('client_id'); + + if (clientID && clientID.match(/^[A-Za-z0-9_-]{43}$/)) { + const clientListUrl = `${clientBaseListUrl}/${clientID}`; + const response = await fetch(clientListUrl); + const res: Client = await response.json(); + return { clientData: res }; + } else { + console.warn('no client_id supplied, or not valid 32bit Base64Url'); + return { clientData: undefined }; + } + } catch (error) { + console.error(error); + return { clientData: undefined }; + } +}; + +export const fetchBannerContent = async ( + loginBannerUrl: string +): Promise> => { + try { + const response = await fetch(loginBannerUrl); + const data = await response.json(); + return Object.values(data) as Array; + } catch (error) { + console.error('Failed to fetch banner content:', error); + return []; + } +};