diff --git a/package-lock.json b/package-lock.json index e72401c..bc7b6ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ciscode/ui-authentication-kit", - "version": "1.0.13", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciscode/ui-authentication-kit", - "version": "1.0.13", + "version": "1.0.15", "license": "ISC", "devDependencies": { "@eslint/js": "^9.39.4", @@ -14,8 +14,8 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.13.1", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.3.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.1.8", "autoprefixer": "^10.4.20", @@ -1788,30 +1788,23 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { diff --git a/package.json b/package.json index 58b444d..51327e7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:cov": "vitest --coverage", + "test:cov": "vitest run --coverage", "verify": "npm run lint && npm run typecheck && npm run test:cov", "prepublishOnly": "npm run verify && npm run build" }, @@ -53,8 +53,8 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.13.1", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.3.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.1.8", "autoprefixer": "^10.4.20", diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index f6e671c..11a6ef0 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -24,7 +24,7 @@ interface Props { } /* ---------- tiny in-file route guard ----------------------- */ -const RequireAuth: React.FC<{ children: JSX.Element }> = ({ children }) => { +const RequireAuth: React.FC<{ children: React.ReactElement }> = ({ children }) => { const { isAuthenticated } = useAuthState(); const location = useLocation(); return isAuthenticated @@ -215,7 +215,7 @@ export const AuthProvider: React.FC = ({ config, children }) => { {/* everything else protected */} {children as JSX.Element}} + element={{children as React.ReactElement}} /> diff --git a/tests/components/ProfilePage.test.tsx b/tests/components/ProfilePage.test.tsx new file mode 100644 index 0000000..2917c40 --- /dev/null +++ b/tests/components/ProfilePage.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AuthStateCtx } from '../../src/context/AuthStateContext'; +import { ProfilePage } from '../../src/components/ProfilePage'; + +const mockUser = { + id: 'u1', + email: 'user@example.com', + name: 'John Doe', + roles: ['admin'], + modules: ['menus'], + tenantId: 't1', +}; + +const mockApi = { + get: vi.fn().mockResolvedValue({ + data: { + data: { + email: 'user@example.com', + fullname: { fname: 'John', lname: 'Doe' }, + username: 'johndoe', + }, + }, + }), + patch: vi.fn().mockResolvedValue({ data: {} }), + interceptors: { + request: { use: vi.fn(), eject: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn() }, + }, +} as any; + +const mockSetUser = vi.fn(); + +function renderProfile(user = mockUser, apiOverride = mockApi) { + return render( + + + + ); +} + +describe('ProfilePage', () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.patch.mockReset(); + mockSetUser.mockReset(); + mockApi.get.mockResolvedValue({ + data: { + data: { + email: 'user@example.com', + fullname: { fname: 'John', lname: 'Doe' }, + username: 'johndoe', + }, + }, + }); + mockApi.patch.mockResolvedValue({ data: {} }); + }); + + it('renders "No user data available" when user is null', () => { + render( + + + + ); + expect(screen.getByText(/No user data available/i)).toBeInTheDocument(); + }); + + it('loads and displays profile data on mount', async () => { + renderProfile(); + await waitFor(() => { + // Avatar initial should be computed from loaded name + expect(screen.getByText('J')).toBeInTheDocument(); + }); + }); + + it('handles API error gracefully on load', async () => { + const errApi = { + ...mockApi, + get: vi.fn().mockRejectedValue(new Error('network error')), + } as any; + // Should not throw + renderProfile(mockUser, errApi); + await waitFor(() => { + // page still renders without crashing + expect(document.body).toBeDefined(); + }); + }); + + it('enters edit mode when Edit button is clicked', async () => { + renderProfile(); + await waitFor(() => screen.getByTitle('Edit profile')); + fireEvent.click(screen.getByTitle('Edit profile')); + // After clicking Edit, save/cancel buttons should appear + await waitFor(() => expect(screen.getByText('Save changes')).toBeInTheDocument()); + }); + + it('saves profile and shows success toast', async () => { + renderProfile(); + await waitFor(() => screen.getByTitle('Edit profile')); + fireEvent.click(screen.getByTitle('Edit profile')); + + await waitFor(() => screen.getByText('Save changes')); + fireEvent.click(screen.getByText('Save changes')); + + await waitFor(() => { + expect(mockApi.patch).toHaveBeenCalledWith('/api/auth/me', expect.any(Object)); + }); + // Toast should appear with success + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()); + }); + + it('shows error toast when save fails', async () => { + mockApi.patch.mockRejectedValueOnce(new Error('save error')); + renderProfile(); + await waitFor(() => screen.getByTitle('Edit profile')); + fireEvent.click(screen.getByTitle('Edit profile')); + + await waitFor(() => screen.getByText('Save changes')); + fireEvent.click(screen.getByText('Save changes')); + + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()); + expect(screen.getByText(/Save failed/i)).toBeInTheDocument(); + }); + + it('cancels editing and reverts to original values', async () => { + renderProfile(); + await waitFor(() => screen.getByTitle('Edit profile')); + fireEvent.click(screen.getByTitle('Edit profile')); + + await waitFor(() => screen.getByText('Cancel')); + fireEvent.click(screen.getByText('Cancel')); + + // Should be back to view mode (Edit profile button visible again) + await waitFor(() => expect(screen.getByTitle('Edit profile')).toBeInTheDocument()); + }); +}); diff --git a/tests/components/SocialButton.test.tsx b/tests/components/SocialButton.test.tsx new file mode 100644 index 0000000..4786ad3 --- /dev/null +++ b/tests/components/SocialButton.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SocialButton } from '../../src/components/actions/SocialButton'; + +describe('SocialButton', () => { + it('renders an image with the given icon src', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/icon.svg'); + expect(img).toHaveAttribute('alt', 'Google'); + }); + + it('renders the label text when provided', () => { + render(); + expect(screen.getByText('Sign in with Google')).toBeInTheDocument(); + }); + + it('hides the label when no label is provided', () => { + render(); + // img with empty alt is decorative (no role); find via querySelector + const img = document.querySelector('img')!; + expect(img).toHaveAttribute('alt', ''); + expect(img).toHaveAttribute('src', 'icon.svg'); + }); +}); diff --git a/tests/exports.test.ts b/tests/exports.test.ts new file mode 100644 index 0000000..961b3a7 --- /dev/null +++ b/tests/exports.test.ts @@ -0,0 +1,37 @@ +/** + * Smoke test: importing from the package entry point covers + * src/index.ts and src/main/app.ts re-export lines. + */ +import { describe, it, expect } from 'vitest'; + +import { + AuthProvider, + useAuthState, + useHasRole, + useHasModule, + useCan, + RequirePermissions, + RbacContext, + RbacProvider, + useGrant, +} from '../src/main/app'; + +import { ProfilePage } from '../src/components/ProfilePage'; + +describe('package exports', () => { + it('exports all expected symbols from main/app', () => { + expect(AuthProvider).toBeDefined(); + expect(useAuthState).toBeDefined(); + expect(useHasRole).toBeDefined(); + expect(useHasModule).toBeDefined(); + expect(useCan).toBeDefined(); + expect(RequirePermissions).toBeDefined(); + expect(RbacContext).toBeDefined(); + expect(RbacProvider).toBeDefined(); + expect(useGrant).toBeDefined(); + }); + + it('exports ProfilePage from components', () => { + expect(ProfilePage).toBeDefined(); + }); +}); diff --git a/tests/pages/auth/authPages.test.tsx b/tests/pages/auth/authPages.test.tsx new file mode 100644 index 0000000..a7f0cdb --- /dev/null +++ b/tests/pages/auth/authPages.test.tsx @@ -0,0 +1,289 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { AuthConfigContext } from '../../../src/context/AuthConfigContext'; +import { AuthStateCtx } from '../../../src/context/AuthStateContext'; + +vi.mock('@ciscode/ui-translate-core', () => ({ + useT: () => (key: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? key, +})); + +// SVG imports used in the page +vi.mock('../../../src/assets/icons/google-icon-svgrepo-com.svg', () => ({ default: 'google.svg' })); +vi.mock('../../../src/assets/icons/microsoft-svgrepo-com.svg', () => ({ default: 'microsoft.svg' })); + +import { ForgotPasswordPage } from '../../../src/pages/auth/ForgotPasswordPage'; +import { ResetPasswordPage } from '../../../src/pages/auth/ResetPasswordPage'; +import { VerifyEmailPage } from '../../../src/pages/auth/VerifyEmailPage'; +import { GoogleCallbackPage } from '../../../src/pages/auth/GoogleCallbackPage'; +import { SignInPage } from '../../../src/pages/auth/SignInPage'; +import { SignUpPage } from '../../../src/pages/auth/SignUpPage'; + +const baseConfig = { + baseUrl: 'https://api.example.com', + colors: { bg: 'bg-sky-500', text: 'text-white', border: 'border-sky-500' }, +}; + +const mockApi = { + post: vi.fn().mockResolvedValue({ data: {} }), + get: vi.fn().mockResolvedValue({ data: {} }), + interceptors: { + request: { use: vi.fn(), eject: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn() }, + }, +} as any; + +const mockAuthState = { + isAuthenticated: false, + user: null, + accessToken: null, + api: mockApi, + login: vi.fn(), + logout: vi.fn(), + setUser: vi.fn(), +}; + +function wrap( + ui: React.ReactElement, + { initialPath = '/', config = baseConfig, authState = mockAuthState } = {} +) { + return render( + + + + + + + + + + ); +} + +// ─── ForgotPasswordPage ──────────────────────────────────────────────────── + +describe('ForgotPasswordPage', () => { + beforeEach(() => { + mockApi.post.mockReset(); + }); + + it('renders the page title and email input', () => { + wrap(); + expect(screen.getByText('Forgot your password?')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('form.emailPlaceholder')).toBeInTheDocument(); + }); + + it('shows success message after submitting a valid email', async () => { + mockApi.post.mockResolvedValueOnce({ data: {} }); + wrap(); + + fireEvent.change(screen.getByPlaceholderText('form.emailPlaceholder'), { + target: { value: 'test@example.com' }, + }); + fireEvent.click(screen.getByText('Send Reset Link')); + + await waitFor(() => + expect(screen.getByText(/If the email exists/i)).toBeInTheDocument() + ); + }); + + it('shows inline error when API call fails', async () => { + mockApi.post.mockRejectedValueOnce({ isAxiosError: true, message: 'Not found', response: { data: { message: 'Email not found' } } }); + wrap(); + + fireEvent.change(screen.getByPlaceholderText('form.emailPlaceholder'), { + target: { value: 'bad@example.com' }, + }); + fireEvent.click(screen.getByText('Send Reset Link')); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + }); + + it('renders brand name when no logoUrl is provided', () => { + wrap(, { config: { ...baseConfig, brandName: 'TestBrand' } as any }); + expect(screen.getByText('TestBrand')).toBeInTheDocument(); + }); + + it('renders logo image when logoUrl is provided', () => { + wrap(, { config: { ...baseConfig, logoUrl: 'https://logo.example.com/img.png' } as any }); + const img = screen.getByAltText('Brand Logo'); + expect(img).toHaveAttribute('src', 'https://logo.example.com/img.png'); + }); +}); + +// ─── ResetPasswordPage ───────────────────────────────────────────────────── + +describe('ResetPasswordPage', () => { + beforeEach(() => { + mockApi.post.mockReset(); + }); + + it('renders the page title', () => { + wrap(, { initialPath: '/?token=abc123' }); + expect(screen.getByRole('heading', { name: /Reset your password/i })).toBeInTheDocument(); + }); + + it('shows mismatch error when passwords differ', async () => { + wrap(, { initialPath: '/?token=abc123' }); + + fireEvent.change(screen.getByPlaceholderText('form.passwordPlaceholder'), { + target: { value: 'password123' }, + }); + fireEvent.change(screen.getByPlaceholderText('Re-enter your password'), { + target: { value: 'differentpwd' }, + }); + fireEvent.submit(document.querySelector('form')!); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + }); + + it('renders brand name fallback when no logoUrl', () => { + wrap(, { + initialPath: '/?token=abc', + config: { ...baseConfig, brandName: 'BrandX' } as any, + }); + expect(screen.getByText('BrandX')).toBeInTheDocument(); + }); +}); + +// ─── VerifyEmailPage ─────────────────────────────────────────────────────── + +describe('VerifyEmailPage', () => { + it('renders page content', () => { + wrap(, { initialPath: '/?email=user@example.com' }); + // Page renders without crashing + expect(document.body).toBeDefined(); + }); + + it('renders brand name when no logoUrl', () => { + wrap(, { + initialPath: '/', + config: { ...baseConfig, brandName: 'VerifyBrand' } as any, + }); + // Brand name appears in at least one location + expect(screen.getAllByText('VerifyBrand').length).toBeGreaterThan(0); + }); + + it('renders logo when logoUrl is provided', () => { + wrap(, { + initialPath: '/?email=test@example.com', + config: { ...baseConfig, logoUrl: 'https://verify.example.com/logo.png' } as any, + }); + const imgs = screen.getAllByAltText('Brand Logo'); + expect(imgs.length).toBeGreaterThan(0); + }); +}); + +// ─── GoogleCallbackPage ──────────────────────────────────────────────────── + +describe('GoogleCallbackPage', () => { + it('renders loading text', () => { + // Prevent real window.location.replace + Object.defineProperty(window, 'location', { + value: { ...window.location, replace: vi.fn(), search: '?accessToken=tok&refreshToken=ref' }, + writable: true, + }); + render( + + + } /> + + + ); + expect(screen.getByText(/Finishing Google sign-in/i)).toBeInTheDocument(); + }); +}); + +// ─── SignInPage ──────────────────────────────────────────────────────────── + +describe('SignInPage', () => { + beforeEach(() => { + mockAuthState.login.mockReset(); + }); + + it('renders sign in form', () => { + wrap(); + expect(screen.getAllByText('SignInPage.signIn').length).toBeGreaterThan(0); + }); + + it('calls login on form submit', async () => { + mockAuthState.login.mockResolvedValueOnce(undefined); + wrap(); + + fireEvent.change(screen.getByPlaceholderText('name@company.com'), { + target: { value: 'user@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('form.passwordPlaceholder'), { + target: { value: 'password123' }, + }); + fireEvent.submit(document.querySelector('form')!); + + await waitFor(() => expect(mockAuthState.login).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'password123', + })); + }); + + it('shows error when login fails', async () => { + mockAuthState.login.mockRejectedValueOnce({ + isAxiosError: true, + message: 'Unauthorized', + response: { data: { message: 'Invalid credentials' } }, + }); + wrap(); + + fireEvent.change(screen.getByPlaceholderText('name@company.com'), { + target: { value: 'bad@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('form.passwordPlaceholder'), { + target: { value: 'wrongpwd' }, + }); + fireEvent.submit(document.querySelector('form')!); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + }); + + it('renders oauth provider buttons when configured', () => { + wrap(, { + config: { ...baseConfig, oauthProviders: ['google', 'microsoft'] } as any, + }); + const imgs = screen.getAllByRole('img'); + // Both google and microsoft icon imgs should be present + expect(imgs.length).toBeGreaterThan(1); + }); +}); + +// ─── SignUpPage ──────────────────────────────────────────────────────────── + +describe('SignUpPage', () => { + beforeEach(() => { + mockApi.post.mockReset(); + }); + + it('renders the signup form', () => { + wrap(); + // The page renders without crashing - look for a typical field + const inputs = document.querySelectorAll('input'); + expect(inputs.length).toBeGreaterThan(0); + }); + + it('renders brand name when no logoUrl', () => { + wrap(, { + config: { ...baseConfig, brandName: 'SignUpBrand' } as any, + }); + expect(screen.getAllByText('SignUpBrand').length).toBeGreaterThan(0); + }); + + it('renders custom sign up fields when provided', () => { + wrap(, { + config: { + ...baseConfig, + signUpCustomFields: [ + { name: 'company', label: 'Company', type: 'text', placeholder: 'Your company' }, + ], + } as any, + }); + expect(screen.getByPlaceholderText('Your company')).toBeInTheDocument(); + }); +}); diff --git a/tests/utils/errorHelpers.test.ts b/tests/utils/errorHelpers.test.ts new file mode 100644 index 0000000..68a99ed --- /dev/null +++ b/tests/utils/errorHelpers.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { extractHttpErrorMessage } from '../../src/utils/errorHelpers'; + +function makeAxiosError(overrides: Record = {}) { + return { + isAxiosError: true, + message: 'Request failed', + response: { + data: {}, + }, + ...overrides, + }; +} + +describe('extractHttpErrorMessage', () => { + it('returns the string directly when err is a string', () => { + expect(extractHttpErrorMessage('plain error')).toBe('plain error'); + }); + + it('returns details.message when present in axios response', () => { + const err = makeAxiosError({ response: { data: { details: { message: 'Detail msg' } } } }); + expect(extractHttpErrorMessage(err)).toBe('Detail msg'); + }); + + it('returns data.message when details.message is absent', () => { + const err = makeAxiosError({ response: { data: { message: 'Top-level msg' } } }); + expect(extractHttpErrorMessage(err)).toBe('Top-level msg'); + }); + + it('returns data.details.error as fallback in axios response', () => { + const err = makeAxiosError({ response: { data: { details: { error: 'Detail error' } } } }); + expect(extractHttpErrorMessage(err)).toBe('Detail error'); + }); + + it('returns err.message when axios response has no useful data', () => { + const err = makeAxiosError({ response: { data: {} } }); + expect(extractHttpErrorMessage(err)).toBe('Request failed'); + }); + + it('returns native Error.message for a non-axios Error', () => { + expect(extractHttpErrorMessage(new Error('native error'))).toBe('native error'); + }); + + it('returns generic fallback for unknown objects', () => { + expect(extractHttpErrorMessage({ something: 'unknown' })).toBe('An unexpected error occurred'); + }); + + it('returns generic fallback for null', () => { + expect(extractHttpErrorMessage(null)).toBe('An unexpected error occurred'); + }); + + it('trims whitespace from response fields', () => { + const err = makeAxiosError({ response: { data: { message: ' trimmed ' } } }); + expect(extractHttpErrorMessage(err)).toBe('trimmed'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 84bb830..1bbe266 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,12 @@ "outDir": "dist", "moduleResolution": "node", "allowSyntheticDefaultImports": true, - "emitDeclarationOnly": false + "emitDeclarationOnly": false, + "paths": { + "react": ["../../node_modules/react"], + "react-dom": ["../../node_modules/react-dom"], + "react-router-dom": ["../../node_modules/react-router-dom"] + } }, "include": ["src", "src/models", "src/vite-env.d.ts", "../template-fe-model-users/src/models/table"] } \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index bfc459b..5b74f3a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,14 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["tests/setup.ts"], include: ["tests/**/*.{test,spec}.{ts,tsx}"], + coverage: { + provider: "v8", + include: ["src/**"], + exclude: [ + "src/models/**", + "src/vite-env.d.ts", + "src/assets/**", + ], + }, }, });