From 4e83825c625ab331eaee021b7116295ab11e57db Mon Sep 17 00:00:00 2001 From: Michele Moio Date: Tue, 26 Nov 2024 23:00:54 +0100 Subject: [PATCH 1/6] feat: [OI-249] spinner on idps fetch --- .../src/main/webui/src/pages/login/Login.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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/Login.tsx index 2ba45d8d..eb087f94 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/Login.tsx @@ -20,6 +20,7 @@ import type { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; import { ImageWithFallback } from '../../components/ImageFallback'; import SpidSelect from './SpidSelect'; import SpidModal from './SpidModal'; +import LoadingButton from '@mui/lab/LoadingButton'; type BannerContent = { enable: boolean; @@ -304,17 +305,20 @@ const Login = () => { idpList={idpList} /> - + + ); +}; 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 d2dbc6e7..4be1cc17 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; @@ -75,7 +75,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 88% 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 a8dcf157..80693aee 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 @@ -5,13 +5,13 @@ 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 { IDP_PLACEHOLDER_IMG } from '../../utils/constants'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import { ImageWithFallback } from '../../components/ImageFallback'; +import { IdentityProvider, IdentityProviders } from '../../../utils/IDPS'; +import SpidBig from '../../../assets/spid_big.svg'; +import { ENV } from '../../../utils/env'; +import { IDP_PLACEHOLDER_IMG } 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 61% 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 eb087f94..7417fc37 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,52 +1,23 @@ -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 } 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 { IDP_PLACEHOLDER_IMG } 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'; -import LoadingButton from '@mui/lab/LoadingButton'; - -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, @@ -70,81 +41,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}`; @@ -185,8 +90,6 @@ const Login = () => { return ; } - const columnsOccupiedByAlert = 5; - return ( @@ -305,45 +208,13 @@ const Login = () => { idpList={idpList} /> - setOpenSpidModal(true)} - startIcon={} - 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/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 []; + } +}; From 372b3e482b7723645152ecc789968c072f7d8f5c Mon Sep 17 00:00:00 2001 From: Michele Moio Date: Fri, 6 Dec 2024 09:40:37 +0100 Subject: [PATCH 3/6] fix: [OI-249] SpidModal skeleton --- .../src/pages/login/components/SpidButton.tsx | 6 +- .../src/pages/login/components/SpidModal.tsx | 87 +++++++++++++++---- .../src/main/webui/src/pages/login/index.tsx | 6 +- 3 files changed, 72 insertions(+), 27 deletions(-) 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 index db0fd673..3b6892fe 100644 --- 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 @@ -12,21 +12,17 @@ export const SpidIconWrapper = () => ( ); export type SpidButtonProps = { - loading: boolean; onClick: () => void; }; -export const SpidButton = ({ loading, onClick }: SpidButtonProps) => { +export const SpidButton = ({ onClick }: SpidButtonProps) => { const { t } = useTranslation(); return ( } sx={{ diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx index 4be1cc17..53a99a2a 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.tsx @@ -1,4 +1,12 @@ -import { Button, Dialog, Grid, Icon, Typography } from '@mui/material'; +import { + Button, + Dialog, + Grid, + Icon, + Skeleton, + Stack, + Typography, +} from '@mui/material'; import { useTranslation } from 'react-i18next'; import { IdentityProvider, IdentityProviders } from '../../../utils/IDPS'; @@ -12,6 +20,7 @@ type Props = { openSpidModal: boolean; setOpenSpidModal: (openDialog: boolean) => void; idpList: IdentityProviders; + loading: boolean; }; export const getSPID = (IDP: IdentityProvider) => { @@ -71,14 +80,71 @@ const IdpListSelection = ({ )); -const SpidModal = ({ openSpidModal, setOpenSpidModal, idpList }: Props) => { +const SpidModal = ({ + openSpidModal, + setOpenSpidModal, + idpList, + loading, +}: Props) => { const { t } = useTranslation(); + const ContentSelection = () => { + return ( + <> + {idpList?.identityProviders?.length ? ( + + + + + + ) : ( + + {t('spidSelect.placeholder')} + + )} + + ); + }; + + const IdpsOverlay = () => { + const ImgSkeleton = () => ( + + ); + return ( + + + + + + + + + + + + + ); + }; + return ( setOpenSpidModal(false)} + aria-busy={loading} + aria-live="polite" > { > {t('spidSelect.modalTitle')} - {idpList?.identityProviders?.length ? ( - - - - - - ) : ( - - {t('spidSelect.placeholder')} - - )} + {loading ? : } - - )); - -const SpidModal = ({ - openSpidModal, - setOpenSpidModal, - idpList, - loading, -}: Props) => { - const { t } = useTranslation(); - - const ContentSelection = () => { - return ( - <> - {idpList?.identityProviders?.length ? ( - - - - - - ) : ( - - {t('spidSelect.placeholder')} - - )} - - ); - }; - - const IdpsOverlay = () => { - const ImgSkeleton = () => ( - - ); - return ( - - - - - - - - - - - - - ); - }; - - return ( - setOpenSpidModal(false)} - aria-busy={loading} - aria-live="polite" - > - - {t('spidSelect.modalTitle')} - - {loading ? : } - - - - - ); -}; - -export default SpidModal; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/SpidModal.test.tsx similarity index 83% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/SpidModal.test.tsx index 603a59ed..4e9e500b 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/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 SpidModal from './SpidModal'; +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'), })); @@ -25,6 +25,7 @@ describe('SpidModal', () => { it('renders the modal with identity providers', () => { render( void; + idpList?: IdentityProviders; + loading?: boolean; +}; + +export const NoProviders = () => { + const { t } = useTranslation(); + + return ( + + {t('spidSelect.placeholder')} + + ); +}; + +export const ContentSelection = ({ + idpList, +}: { + idpList?: IdentityProviders; +}) => { + const noSpidProvidersFound = !idpList?.identityProviders?.length; + + return noSpidProvidersFound ? ( + + ) : ( + + ); +}; + +const SpidModal = ({ + openSpidModal, + setOpenSpidModal, + idpList, + loading, +}: Props) => { + const { t } = useTranslation(); + + return ( + setOpenSpidModal(false)} + aria-busy={loading} + aria-live="polite" + > + + {t('spidSelect.modalTitle')} + + {loading ? : } + + + + + ); +}; + +export default SpidModal; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx deleted file mode 100644 index 80693aee..00000000 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Fragment } from 'react'; -import Icon from '@mui/material/Icon'; -import Grid from '@mui/material/Grid'; -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 { IDP_PLACEHOLDER_IMG } from '../../../utils/constants'; -import { trackEvent } from '../../../services/analyticsService'; -import { forwardSearchParams } from '../../../utils/utils'; -import { ImageWithFallback } from '../../../components/ImageFallback'; - -type Props = { - onBack: () => void; - idpList: IdentityProviders; -}; - -export const getSPID = (IDP: IdentityProvider) => { - const params = forwardSearchParams(IDP.entityID); - const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; - trackEvent( - 'LOGIN_IDP_SELECTED', - { - SPID_IDP_NAME: IDP.name, - SPID_IDP_ID: IDP.entityID, - FORWARD_PARAMETERS: params, - }, - () => window.location.assign(redirectUrl) - ); -}; - -export const SpidList = ({ idpList }: { idpList: IdentityProviders }) => - idpList.identityProviders.map((IDP, i) => ( - - - - )); - -const SpidSelect = ({ onBack, idpList }: Props) => { - const { t } = useTranslation(); - - return ( - - - - - - - - - - - {t('spidSelect.title')} - - - - - {idpList?.identityProviders?.length ? ( - - ) : ( - - {t('spidSelect.placeholder')} - - )} - - - - - - - - - ); -}; - -export default SpidSelect; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx similarity index 92% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.test.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx index 3a24d1d2..3259c8e4 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx @@ -2,8 +2,8 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { vi } from 'vitest'; -import { ENV } from '../../../utils/env'; -import SpidSelect from './SpidSelect'; +import { ENV } from '../../../../utils/env'; +import SpidSelect from '../SpidSelect'; const oldWindowLocation = global.window.location; beforeAll(() => { diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx new file mode 100644 index 00000000..fe135a95 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx @@ -0,0 +1,76 @@ +import { Fragment } from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { IdentityProviders } from '../../../../utils/IDPS'; +import SpidBig from '../../../../assets/spid_big.svg'; +import { SpidSkeleton } from '../SpidSkeleton'; +import { ContentSelection } from '../SpidModal'; + +type SpidSelectProps = { + onBack: () => void; + idpList?: IdentityProviders; + loading?: boolean; +}; + +const SpidSelect = ({ onBack, idpList, loading }: SpidSelectProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + {t('spidSelect.title')} + + + {loading ? : } + + + + + + + ); +}; + +export default SpidSelect; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx new file mode 100644 index 00000000..7dbe7733 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx @@ -0,0 +1,69 @@ +import { Button, Grid, Icon } from '@mui/material'; +import { ImageWithFallback } from '../../../components/ImageFallback'; +import { IDP_PLACEHOLDER_IMG } from '../../../utils/constants'; +import { IdentityProvider } from '../../../utils/IDPS'; +import { trackEvent } from '../../../services/analyticsService'; +import { ENV } from '../../../utils/env'; +import { forwardSearchParams } from '../../../utils/utils'; + +export const getSPID = (IDP: IdentityProvider) => { + const params = forwardSearchParams(IDP.entityID); + const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; + trackEvent( + 'LOGIN_IDP_SELECTED', + { + SPID_IDP_NAME: IDP.name, + SPID_IDP_ID: IDP.entityID, + FORWARD_PARAMETERS: params, + }, + () => window.location.assign(redirectUrl) + ); +}; + +export const SpidSelection = ({ + identityProviders, +}: { + identityProviders: Array; +}) => ( + + + {identityProviders?.map((IDP, i) => ( + + + + ))} + + +); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx new file mode 100644 index 00000000..c76f4ecb --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx @@ -0,0 +1,33 @@ +import { Skeleton, Stack } from '@mui/material'; + +export const SpidSkeleton = () => { + const ImgSkeleton = () => ( + + ); + return ( + + + + + + + + + + + + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx index 3e2f9412..9a395900 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx @@ -44,8 +44,7 @@ const Login = () => { const [openSpidModal, setOpenSpidModal] = useState(false); const [showIDPS, setShowIDPS] = useState(false); - const { bannerContent, clientData, idpList } = useLoginData(); - const idpLoading = !idpList?.identityProviders?.length; + const { bannerQuery, clientQuery, idpQuery } = useLoginData(); const { t } = useTranslation(); const columnsOccupiedByAlert = 5; @@ -75,19 +74,25 @@ const Login = () => { const redirectPrivacyLink = () => trackEvent('LOGIN_PRIVACY', { SPID_IDP_NAME: 'LOGIN_PRIVACY' }, () => window.location.assign( - clientData?.policyUri || ENV.URL_FOOTER.PRIVACY_DISCLAIMER + clientQuery.data?.policyUri || ENV.URL_FOOTER.PRIVACY_DISCLAIMER ) ); const redirectToTOS = () => trackEvent('LOGIN_TOS', { SPID_IDP_NAME: 'LOGIN_TOS' }, () => window.location.assign( - clientData?.tosUri || ENV.URL_FOOTER.TERMS_AND_CONDITIONS + clientQuery.data?.tosUri || ENV.URL_FOOTER.TERMS_AND_CONDITIONS ) ); if (showIDPS) { - return ; + return ( + + ); } return ( @@ -123,15 +128,15 @@ const Login = () => { - {clientData?.logoUri && ( - - + + + {clientQuery.isFetched && ( { maxHeight: '100px', objectFit: 'cover', }} - src={clientData?.logoUri} - alt={clientData?.friendlyName} + src={clientQuery.data?.logoUri} + alt={clientQuery.data?.friendlyName || 'PagoPa Logo'} placeholder={IDP_PLACEHOLDER_IMG} /> - + )} - )} + {ENV.ENABLED_SPID_TEMPORARY_SELECT && ( @@ -166,8 +171,8 @@ const Login = () => { )} - {bannerContent && - bannerContent.map( + {bannerQuery.isSuccess && + bannerQuery.data.map( (bc, index) => bc.enable && ( @@ -205,8 +210,8 @@ const Login = () => { setOpenSpidModal(true)} /> 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 index 9ff4935d..b85aeb44 100644 --- 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 @@ -15,60 +15,59 @@ export type Client = { tosUri: string; }; +export type IDPList = { + identityProviders: Array; + richiediSpid: 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 }; + const response = await fetch(idpListUrl); + if (!response.ok) { + throw new Error(`Failed to fetch IDP list: ${response.statusText}`); } + + 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 out: IDPList = { + identityProviders: rawIDPS, + richiediSpid: 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/', + }; + + return out; }; export const getClientData = async (clientBaseListUrl: string) => { - try { - const query = new URLSearchParams(window.location.search); - const clientID = query.get('client_id'); + 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 }; + if (!clientID || !clientID.match(/^[A-Za-z0-9_-]{43}$/)) { + throw new Error('Invalid or missing client_id'); } + + const clientListUrl = `${clientBaseListUrl}/${clientID}`; + const response = await fetch(clientListUrl); + if (!response.ok) { + throw new Error(`Failed to fetch client data: ${response.statusText}`); + } + + return await response.json(); }; 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 []; + const response = await fetch(loginBannerUrl); + if (!response.ok) { + throw new Error(`Failed to fetch banner content: ${response.statusText}`); } + + const data = await response.json(); + return Object.values(data) as Array; }; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock b/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock index 6fd21050..54a24cbb 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock +++ b/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock @@ -984,6 +984,18 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.23.0.tgz#54e3562ebd264ef5839f8091618310c40d43d8a9" integrity sha512-lqCK5GQC8fNo0+JvTSxcG7YB1UKYp8yrNLhsArlvPWN+16ovSZgoehlVHg6X0sSWPUkpjRBR5TuR12ZugowZ4g== +"@tanstack/query-core@5.62.2": + version "5.62.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.62.2.tgz#4eef3201422f246788fb41d01662c2dea3136d9a" + integrity sha512-LcwVcC5qpsDpHcqlXUUL5o9SaOBwhNkGeV+B06s0GBoyBr8FqXPuXT29XzYXR36lchhnerp6XO+CWc84/vh7Zg== + +"@tanstack/react-query@^5.62.2": + version "5.62.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.62.2.tgz#fbcb8f991ddcf484ce7968fb58bb4790d6c98cd3" + integrity sha512-fkTpKKfwTJtVPKVR+ag7YqFgG/7TRVVPzduPAUF9zRCiiA8Wu305u+KJl8rCrh98Qce77vzIakvtUyzWLtaPGA== + dependencies: + "@tanstack/query-core" "5.62.2" + "@testing-library/dom@^10.2.0": version "10.2.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.2.0.tgz#d3b22515bc0603a06f119c6ae6670669c3f2085f" From 79354d1fca7daa6a8ccdd88d7553b601e6812a68 Mon Sep 17 00:00:00 2001 From: Michele Moio Date: Fri, 6 Dec 2024 12:26:40 +0100 Subject: [PATCH 5/6] test: [OI-249] login & api tests --- .../main/webui/src/pages/login/Login.test.tsx | 197 +++++++----------- .../login/components/SpidModal/index.tsx | 1 + .../src/main/webui/src/pages/login/index.tsx | 13 +- .../src/main/webui/src/services/api.test.ts | 172 ++++++++------- 4 files changed, 183 insertions(+), 200 deletions(-) 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 55a97b31..d5bec399 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,133 +1,98 @@ -/* eslint-disable functional/immutable-data */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, afterEach, test, vi, Mock } from 'vitest'; +import { Mock, vi } from 'vitest'; +import Login from '../login'; +import { useLoginData } from '../../hooks/useLoginData'; import { ENV } from '../../utils/env'; -import { i18nTestSetup } from '../../__tests__/i18nTestSetup'; -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; - -beforeAll(() => { - // Mock window location - Object.defineProperty(window, 'location', { value: { assign: vi.fn() } }); -}); - -afterAll(() => { - Object.defineProperty(window, 'location', { value: oldWindowLocation }); +import { trackEvent } from '../../services/analyticsService'; + +vi.mock('../../hooks/useLoginData'); +vi.mock('../../services/analyticsService', () => ({ + trackEvent: vi.fn(), +})); +vi.mock('@mui/material', async () => { + const actual = await vi.importActual('@mui/material'); + return { + ...actual, + useTheme: () => ({ + spacing: (value: number) => value * 8, + breakpoints: { down: () => '@media (max-width: 960px)' }, + }), + }; }); -// Test cases -test('Renders Login component', () => { - render(); - expect(screen.getByText(LOGIN_TITLE)).toBeInTheDocument(); - expect(screen.getByText(LOGIN_DESCRIPTION)).toBeInTheDocument(); -}); +describe('', () => { + const mockBannerQuery = { + isSuccess: true, + data: [{ enable: true, severity: 'warning', description: 'Test banner' }], + }; + const mockClientQuery = { + isFetched: true, + data: { + friendlyName: 'Test Client', + logoUri: 'https://example.com/logo.png', + }, + }; + const mockIdpQuery = { + isLoading: false, + data: [ + { + entityID: 'idp1', + name: 'IDP 1', + imageUrl: 'https://example.com/idp1.png', + }, + ], + }; -test('Fetches and displays banner alerts', async () => { - const mockBannerResponse = [ - { enable: true, severity: 'warning', description: ALERT_DESCRIPTION }, - ]; - (fetch as Mock).mockResolvedValueOnce({ - json: vi.fn().mockResolvedValueOnce(mockBannerResponse), + beforeEach(() => { + (useLoginData as Mock).mockReturnValue({ + bannerQuery: mockBannerQuery, + clientQuery: mockClientQuery, + idpQuery: mockIdpQuery, + }); }); - render(); - - await waitFor(() => { - expect(screen.getByText(ALERT_DESCRIPTION)).toBeInTheDocument(); + it('renders titles and descriptions', () => { + render(); + expect(screen.getByText('loginPage.title')).toBeInTheDocument(); + expect(screen.getByText('loginPage.description')).toBeInTheDocument(); }); -}); - -test('Handles fetch error for alert message', async () => { - (fetch as Mock).mockRejectedValueOnce(new Error('Fetch failed')); - render(); - - await waitFor(() => { - expect(screen.queryByText(ALERT_DESCRIPTION)).not.toBeInTheDocument(); + it('displays the client logo', () => { + render(); + const logo = screen.getByAltText('Test Client'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'https://example.com/logo.png'); }); -}); -test('Fetches IDP list on mount', async () => { - const mockIDPListResponse = { - identityProviders: [{ entityID: MOCK_IDP_ENTITY_ID }], - richiediSpid: MOCK_RICHIEDI_SPID_URL, - }; - (fetch as Mock).mockResolvedValueOnce({ - json: vi.fn().mockResolvedValueOnce(mockIDPListResponse), + it('shows a banner when bannerQuery is successful', () => { + render(); + expect(screen.getByText('Test banner')).toBeInTheDocument(); }); - render(); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith(ENV.JSON_URL.IDP_LIST); + it('opens the SpidModal on SPID button click', () => { + render(); + const spidButton = screen.getByRole('button', { name: /SPID/i }); + fireEvent.click(spidButton); + expect( + screen.getByRole('dialog', { name: 'spidSelect.modalTitle' }) + ).toBeInTheDocument(); }); -}); - -test('Handles invalid client ID gracefully', async () => { - window.history.pushState({}, '', '?client_id=invalidId'); - render(); - - await waitFor(() => { - expect(screen.queryByAltText('Test Client')).not.toBeInTheDocument(); + it('navigates to CIE login on CIE button click', async () => { + render(); + const cieButton = screen.getByRole('button', { name: /CIE/i }); + fireEvent.click(cieButton); + + await waitFor(() => { + expect(trackEvent).toHaveBeenCalledWith( + 'LOGIN_IDP_SELECTED', + { + SPID_IDP_NAME: 'CIE', + SPID_IDP_ID: ENV.SPID_CIE_ENTITY_ID, + FORWARD_PARAMETERS: expect.any(String), + }, + expect.any(Function) + ); + }); }); }); - -test('Clicking CIE button redirects correctly', () => { - render(); - const buttonCIE = screen.getByText(CIE_LOGIN); - fireEvent.click(buttonCIE); - - expect(global.window.location.assign).toHaveBeenCalledWith( - `${ENV.URL_API.AUTHORIZE}?idp=${ENV.SPID_CIE_ENTITY_ID}` - ); -}); - -test('Displays temporary login alert if enabled', () => { - render(); - 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/SpidModal/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/index.tsx index a65a4dac..3d6f2a7d 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/index.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/index.tsx @@ -51,6 +51,7 @@ const SpidModal = ({ return ( setOpenSpidModal(false)} aria-busy={loading} diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx index 9a395900..9e69343b 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx @@ -27,7 +27,6 @@ export const LinkWrapper = ({ children?: React.ReactNode; }) => ( { privacyLink: `<1>${t('loginPage.privacyAndCondition.privacy')}`, }} components={[ - , - , + , + , ]} /> 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 index 36d5aae9..8cbe6ad4 100644 --- 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 @@ -1,56 +1,65 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getIdpList, getClientData, fetchBannerContent } from './api'; +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable functional/immutable-data */ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { ENV } from '../utils/env'; +import { IdentityProvider } from '../utils/IDPS'; +import { getIdpList, getClientData, fetchBannerContent } from './api'; -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); +vi.stubGlobal('fetch', vi.fn()); -describe('API Functions', () => { - afterEach(() => { - vi.clearAllMocks(); +describe('Utils functions', () => { + beforeEach(() => { + vi.restoreAllMocks(); // Reset mocks before each test }); 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 mockIDPList: Array> = [ + { entityID: 'idp1', name: 'IDP 1', identifier: 'idp-identifier-1' }, + { entityID: 'idp2', name: 'IDP 2', identifier: 'idp-identifier-2' }, + ]; + + it('returns a sorted and enhanced IDP list', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockIDPList, }); - const result = await getIdpList(MOCK_IDP_LIST_URL); + const result = await getIdpList('https://example.com/idp-list'); + const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; - expect(mockFetch).toHaveBeenCalledWith(MOCK_IDP_LIST_URL); - expect(result.idps?.identityProviders).toEqual([ - { ...mockIdps[0], imageUrl: expectedImageUrl }, - ]); - expect(result.idps?.richiediSpid).toBe(SPID_RICHIEDI_URL); + expect(result.identityProviders).toHaveLength(2); + expect(result.identityProviders[0]).toHaveProperty( + 'imageUrl', + `${assetsIDPUrl}/${btoa(mockIDPList[0].entityID)}.png` + ); }); - 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); + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); - expect(mockFetch).toHaveBeenCalledWith(MOCK_IDP_LIST_URL); - expect(result.idps).toBeUndefined(); + await expect(getIdpList('https://example.com/idp-list')).rejects.toThrow( + 'Failed to fetch IDP list: Not Found' + ); }); }); describe('getClientData', () => { + const clientID = '0000000000000000000000000000000000000000000'; + + const mockClientData = { + clientID: clientID, + friendlyName: 'Test Client', + logoUri: 'https://example.com/logo.png', + policyUri: 'https://example.com/policy', + tosUri: 'https://example.com/tos', + }; + beforeEach(() => { vi.stubGlobal('window', { - location: { search: '?client_id=test-client-id' }, + location: { search: `?client_id=${clientID}` }, }); }); @@ -58,71 +67,72 @@ describe('API Functions', () => { 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), + it('fetches client data successfully', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => 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); + const result = await getClientData('https://example.com/clients'); + expect(result).toEqual(mockClientData); }); - it('should return undefined client data for invalid client ID', async () => { + it('throws an error if client_id is invalid or missing', async () => { vi.stubGlobal('window', { - location: { search: '?client_id=invalid-id' }, + location: { search: `?client_id=` }, }); - const result = await getClientData(MOCK_CLIENT_BASE_LIST_URL); - - expect(result.clientData).toBeUndefined(); + await expect( + getClientData('https://example.com/clients') + ).rejects.toThrow('Invalid or missing client_id'); }); - 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); + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized', + }); - expect(result.clientData).toBeUndefined(); + await expect( + getClientData('https://example.com/clients') + ).rejects.toThrow('Failed to fetch client data: Unauthorized'); }); }); 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 mockBannerContent = { + banner1: { + enable: true, + severity: 'info', + description: 'This is a test banner', + }, + banner2: { + enable: false, + severity: 'warning', + description: 'This is another test banner', + }, + }; + + it('returns an array of banner content', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockBannerContent, }); - const result = await fetchBannerContent(MOCK_LOGIN_BANNER_URL); - - expect(mockFetch).toHaveBeenCalledWith(MOCK_LOGIN_BANNER_URL); - expect(result).toEqual(Object.values(mockBannerContent)); + const result = await fetchBannerContent('https://example.com/banner'); + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('description', 'This is a test banner'); }); - 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); + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Forbidden', + }); - expect(mockFetch).toHaveBeenCalledWith(MOCK_LOGIN_BANNER_URL); - expect(result).toEqual([]); + await expect( + fetchBannerContent('https://example.com/banner') + ).rejects.toThrow('Failed to fetch banner content: Forbidden'); }); }); }); From 1b28d775bea0cce65868ca226d16fc5de15bfe23 Mon Sep 17 00:00:00 2001 From: Michele Moio Date: Fri, 6 Dec 2024 15:01:46 +0100 Subject: [PATCH 6/6] test: [OI-249] components & hook tests --- .../webui/src/hooks/useLoginData.test.tsx | 110 ++++++++++++++++++ .../main/webui/src/pages/login/Login.test.tsx | 43 ++++++- .../login/components/SpidSkeleton.test.tsx | 40 +++++++ .../pages/login/components/SpidSkeleton.tsx | 2 + .../src/main/webui/src/services/api.test.ts | 8 +- 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx create mode 100644 src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx 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..c6f26b72 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, Mock } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useLoginData } from './useLoginData'; +import { ENV } from '../utils/env'; +import { fetchBannerContent, getIdpList, getClientData } from '../services/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock API functions +vi.mock('../services/api', () => ({ + fetchBannerContent: vi.fn(), + getIdpList: vi.fn(), + getClientData: vi.fn(), +})); + +// Mock ENV +vi.mock('../utils/env', () => ({ + ENV: { + JSON_URL: { + ALERT: 'mock-alert-url', + IDP_LIST: 'mock-idp-list-url', + CLIENT_BASE_URL: 'mock-client-base-url', + }, + }, +})); + +describe('useLoginData', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient(); + return ( + {children} + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches banner content successfully', async () => { + const mockBannerContent = [ + { title: 'Test Banner', description: 'Test Description' }, + ]; + (fetchBannerContent as Mock).mockResolvedValue(mockBannerContent); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => + expect(result.current.bannerQuery.isSuccess).toBe(true) + ); + expect(fetchBannerContent).toHaveBeenCalledWith(ENV.JSON_URL.ALERT); + expect(result.current.bannerQuery.data).toEqual(mockBannerContent); + }); + + it('fetches identity providers list successfully', async () => { + const mockIdpList = { + providers: [{ name: 'Test IDP', url: 'test-idp-url' }], + }; + (getIdpList as Mock).mockResolvedValue(mockIdpList); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => expect(result.current.idpQuery.isSuccess).toBe(true)); + expect(getIdpList).toHaveBeenCalledWith(ENV.JSON_URL.IDP_LIST); + expect(result.current.idpQuery.data).toEqual(mockIdpList); + }); + + it('fetches client data successfully', async () => { + const mockClientData = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }; + (getClientData as Mock).mockResolvedValue(mockClientData); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => + expect(result.current.clientQuery.isSuccess).toBe(true) + ); + expect(getClientData).toHaveBeenCalledWith(ENV.JSON_URL.CLIENT_BASE_URL); + expect(result.current.clientQuery.data).toEqual(mockClientData); + }); + + it('handles errors correctly', async () => { + (fetchBannerContent as Mock).mockRejectedValue( + new Error('Banner content error') + ); + (getIdpList as Mock).mockRejectedValue(new Error('IDP list error')); + (getClientData as Mock).mockRejectedValue(new Error('Client data error')); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => expect(result.current.bannerQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.bannerQuery.error).toEqual( + new Error('Banner content error') + ); + + await waitFor(() => expect(result.current.idpQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.idpQuery.error).toEqual(new Error('IDP list error')); + + await waitFor(() => expect(result.current.clientQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.clientQuery.error).toEqual( + new Error('Client data error') + ); + }); +}); 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 d5bec399..4c1b3f80 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,9 +1,11 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Mock, vi } from 'vitest'; -import Login from '../login'; +import Login, { LinkWrapper } from '../login'; import { useLoginData } from '../../hooks/useLoginData'; import { ENV } from '../../utils/env'; import { trackEvent } from '../../services/analyticsService'; +import { ThemeProvider } from '@emotion/react'; +import { createTheme } from '@mui/material'; vi.mock('../../hooks/useLoginData'); vi.mock('../../services/analyticsService', () => ({ @@ -20,6 +22,45 @@ vi.mock('@mui/material', async () => { }; }); +describe('LinkWrapper', () => { + const mockOnClick = vi.fn(); + + const renderWithTheme = (component: React.ReactNode) => { + const theme = createTheme(); + return render({component}); + }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders children correctly', () => { + renderWithTheme(Test Link); + + expect(screen.getByText('Test Link')).toBeInTheDocument(); + }); + + it('calls onClick handler when clicked', () => { + renderWithTheme(Click Me); + const link = screen.getByText('Click Me'); + + fireEvent.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('applies correct styles', () => { + renderWithTheme( + Styled Link + ); + const link = screen.getByText('Styled Link'); + + expect(link).toHaveStyle({ + cursor: 'pointer', + }); + }); +}); + describe('', () => { const mockBannerQuery = { isSuccess: true, diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx new file mode 100644 index 00000000..f06c3034 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SpidSkeleton } from './SpidSkeleton'; + +describe('SpidSkeleton', () => { + it('renders the SpidSkeleton component with the correct structure', () => { + render(); + + // Check that the component has the correct role and aria-label + const skeletonContainer = screen.getByRole('status', { name: 'loading' }); + expect(skeletonContainer).toBeInTheDocument(); + + // Check that there are two primary Stack containers + const stackContainers = + skeletonContainer.querySelectorAll('.MuiStack-root'); + expect(stackContainers).toHaveLength(2); + + // Verify the presence of six Skeleton components (3 in each stack) + const skeletons = screen.getAllByRole('presentation'); + expect(skeletons).toHaveLength(6); + + // Check that each Skeleton component has the correct attributes + skeletons.forEach((skeleton) => { + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + }); + + it('has the correct styles for each Skeleton component', () => { + render(); + const skeletons = screen.getAllByRole('presentation'); + + skeletons.forEach((skeleton) => { + expect(skeleton).toHaveStyle({ + borderRadius: '4px', + height: '48px', + width: '148px', + }); + }); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx index c76f4ecb..e422efd0 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx @@ -4,6 +4,8 @@ export const SpidSkeleton = () => { const ImgSkeleton = () => ( { beforeEach(() => { - vi.restoreAllMocks(); // Reset mocks before each test + vi.spyOn(global.Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); describe('getIdpList', () => { @@ -18,7 +22,7 @@ describe('Utils functions', () => { { entityID: 'idp2', name: 'IDP 2', identifier: 'idp-identifier-2' }, ]; - it('returns a sorted and enhanced IDP list', async () => { + it('returns a enhanced IDP list', async () => { (global.fetch as Mock).mockResolvedValueOnce({ ok: true, json: async () => mockIDPList,