diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 5d331c4..c027d4f 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -7,12 +7,30 @@ on: branches: - main jobs: + test-unit: + runs-on: ubuntu-latest + name: Run Unit Tests + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 for testing + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Run unit testing + run: make test_unit deploy: runs-on: ubuntu-latest concurrency: group: resume-book-aws-dev cancel-in-progress: false environment: "AWS DEV" + name: Deploy to AWS DEV + needs: + - test-unit steps: - uses: actions/checkout@v3 - uses: aws-actions/setup-sam@v2 @@ -32,6 +50,8 @@ jobs: permissions: contents: read deployments: write + needs: + - test-unit name: Deploy to Cloudflare Pages DEV environment: "Cloudflare Pages - Dev" steps: @@ -58,6 +78,7 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} test: runs-on: ubuntu-latest + name: Run Live Integration Tests needs: - deploy - deploy-cf-pages-dev @@ -67,7 +88,11 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.11 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 18 - name: Run live testing - run: make test_ci + run: make test_live_integration env: RB_JWT_SECRET: ${{ secrets.RB_JWT_SECRET }} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8146ba2..2325c7f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,8 +7,25 @@ on: branches: - main jobs: + test-unit: + runs-on: ubuntu-latest + name: Run Unit Tests + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 for testing + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Run unit testing + run: make test_unit deploy-aws-dev: runs-on: ubuntu-latest + needs: + - test-unit concurrency: group: resume-book-aws-dev cancel-in-progress: false @@ -27,6 +44,8 @@ jobs: - run: make deploy_dev deploy-cf-pages-dev: runs-on: ubuntu-latest + needs: + - test-unit concurrency: group: resume-book-cf-pages-dev cancel-in-progress: false @@ -69,8 +88,12 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.11 - - name: Run live testing - run: make test_ci + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Run integration testing + run: make test_live_integration env: RB_JWT_SECRET: ${{ secrets.RB_JWT_SECRET }} deploy-aws-prod: diff --git a/Makefile b/Makefile index 7184e6d..1c75af8 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,19 @@ deploy_prod: check_account_prod build deploy_dev: check_account_dev build sam deploy $(common_params) --parameter-overrides $(run_env)=dev -test_ci: +install_deps_python: pip install -r tests/live_integration/requirements.txt + pip install -r api/requirements-testing.txt + +install_deps_node: + cd clientv2 && corepack enable && yarn + +test_live_integration: install_deps_python pytest -rP tests/live_integration/ +test_unit: install_deps_python install_deps_node + pytest -rP api/ + cd clientv2 && yarn test + generate_jwt: python utils/generate_jwt.py \ No newline at end of file diff --git a/code/__init__.py b/api/__init__.py similarity index 100% rename from code/__init__.py rename to api/__init__.py diff --git a/code/app.py b/api/app.py similarity index 100% rename from code/app.py rename to api/app.py diff --git a/code/authorizers/aad.py b/api/authorizers/aad.py similarity index 100% rename from code/authorizers/aad.py rename to api/authorizers/aad.py diff --git a/code/authorizers/combined.py b/api/authorizers/combined.py similarity index 100% rename from code/authorizers/combined.py rename to api/authorizers/combined.py diff --git a/code/authorizers/custom.py b/api/authorizers/custom.py similarity index 100% rename from code/authorizers/custom.py rename to api/authorizers/custom.py diff --git a/code/authorizers/local.py b/api/authorizers/local.py similarity index 100% rename from code/authorizers/local.py rename to api/authorizers/local.py diff --git a/code/authorizers/requirements.txt b/api/authorizers/requirements.txt similarity index 100% rename from code/authorizers/requirements.txt rename to api/authorizers/requirements.txt diff --git a/code/authorizers/roles.py b/api/authorizers/roles.py similarity index 100% rename from code/authorizers/roles.py rename to api/authorizers/roles.py diff --git a/code/authorizers/shared.py b/api/authorizers/shared.py similarity index 100% rename from code/authorizers/shared.py rename to api/authorizers/shared.py diff --git a/api/requirements-testing.txt b/api/requirements-testing.txt new file mode 100644 index 0000000..c967137 --- /dev/null +++ b/api/requirements-testing.txt @@ -0,0 +1,2 @@ +pytest +moto \ No newline at end of file diff --git a/code/requirements.txt b/api/requirements.txt similarity index 100% rename from code/requirements.txt rename to api/requirements.txt diff --git a/code/util/__init__.py b/api/util/__init__.py similarity index 100% rename from code/util/__init__.py rename to api/util/__init__.py diff --git a/code/util/environ.py b/api/util/environ.py similarity index 100% rename from code/util/environ.py rename to api/util/environ.py diff --git a/code/util/logging.py b/api/util/logging.py similarity index 100% rename from code/util/logging.py rename to api/util/logging.py diff --git a/code/util/oai.py b/api/util/oai.py similarity index 100% rename from code/util/oai.py rename to api/util/oai.py diff --git a/code/util/oai_prompts.py b/api/util/oai_prompts.py similarity index 100% rename from code/util/oai_prompts.py rename to api/util/oai_prompts.py diff --git a/code/util/postgres.py b/api/util/postgres.py similarity index 100% rename from code/util/postgres.py rename to api/util/postgres.py diff --git a/code/util/queries.py b/api/util/queries.py similarity index 100% rename from code/util/queries.py rename to api/util/queries.py diff --git a/code/util/s3.py b/api/util/s3.py similarity index 89% rename from code/util/s3.py rename to api/util/s3.py index 3d67914..23ce674 100644 --- a/code/util/s3.py +++ b/api/util/s3.py @@ -2,13 +2,9 @@ from util.logging import get_logger import boto3 import re -import os - -session = boto3.Session(region_name=os.environ.get('AWS_REGION', 'us-east-1')) -s3_client = session.client('s3') logger = get_logger() -def create_presigned_url_from_s3_url(s3_url, expiration=60): +def create_presigned_url_from_s3_url(s3_client, s3_url, expiration=60): """ Generate a presigned URL to share an S3 object @@ -35,7 +31,7 @@ def create_presigned_url_from_s3_url(s3_url, expiration=60): return response -def create_presigned_url_for_put(bucket_name, object_key, file_size, expiration=300): +def create_presigned_url_for_put(s3_client, bucket_name, object_key, file_size, expiration=300): """ Generate a presigned URL to upload an S3 object diff --git a/code/util/secretsmanager.py b/api/util/secretsmanager.py similarity index 100% rename from code/util/secretsmanager.py rename to api/util/secretsmanager.py diff --git a/code/util/server.py b/api/util/server.py similarity index 98% rename from code/util/server.py rename to api/util/server.py index c65a2ee..977e3bb 100644 --- a/code/util/server.py +++ b/api/util/server.py @@ -43,6 +43,7 @@ app = APIGatewayRestResolver(cors=cors_config) session = boto3.Session(region_name=os.environ.get("AWS_REGION", "us-east-1")) secretsmanager = session.client("secretsmanager") +s3_client = session.client('s3') db_config = get_parameter_from_sm(secretsmanager, "infra-resume-book-db-config") openai_client = get_oai_client(db_config['oai_key']) @@ -85,6 +86,7 @@ def shared_get_profile(username): ) if profile_data and 'resumePdfUrl' in profile_data: profile_data["resumePdfUrl"] = create_presigned_url_from_s3_url( + s3_client, profile_data["resumePdfUrl"] ) return Response( @@ -206,6 +208,7 @@ def student_get_s3_presigned(): }, ) presigned_url = create_presigned_url_for_put( + s3_client=s3_client, bucket_name=S3_BUCKET, object_key=f"resume_{username}.pdf", file_size=data["file_size"], @@ -237,7 +240,7 @@ def student_gpt(): for prop in url_properties: if response[prop] != "" and "http://" not in response[prop] and "https://" not in response[prop]: response[prop] = f"http://{response[prop]}" - response['resumePdfUrl'] = create_presigned_url_from_s3_url(f"s3://{S3_BUCKET}/resume_{username}.pdf") + response['resumePdfUrl'] = create_presigned_url_from_s3_url(s3_client, f"s3://{S3_BUCKET}/resume_{username}.pdf") except pydantic.ValidationError as e: return Response( status_code=403, diff --git a/code/util/structs.py b/api/util/structs.py similarity index 100% rename from code/util/structs.py rename to api/util/structs.py diff --git a/api/util/tests/__init__.py b/api/util/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/util/tests/test_secretsmanager.py b/api/util/tests/test_secretsmanager.py new file mode 100644 index 0000000..4d36e19 --- /dev/null +++ b/api/util/tests/test_secretsmanager.py @@ -0,0 +1,42 @@ +import pytest +from moto import mock_aws +import boto3 +import os +import json +from ..secretsmanager import get_parameter_from_sm + + +key_data = {"CLIENT_DATA": "12345", "CLIENT_SECRET": "12345"} +invalid_key_data = '{"name": "Joe", "age": null]' +@pytest.fixture +def sm_client(): + """Fixture to create a mocked KMS client using moto.""" + with mock_aws(): + client = boto3.client('secretsmanager', region_name=os.environ.get("AWS_REGION", "us-east-1")) + yield client + +@pytest.fixture +def sm_valid_key_id(sm_client): + """Fixture to create a mock KMS key.""" + sm_client.create_secret(Name='test-secret', SecretString=json.dumps(key_data)) + return 'test-secret' + + +@pytest.fixture +def sm_invalid_key_id(sm_client): + """Fixture to create a mock KMS key.""" + sm_client.create_secret(Name='test-invalid-secret', SecretString=invalid_key_data) + return 'test-invalid-secret' + +def test_valid_secret(sm_client, sm_valid_key_id): + assert key_data == get_parameter_from_sm(sm_client, sm_valid_key_id) + +def test_invalid_secret(sm_client, sm_invalid_key_id, capfd): + assert get_parameter_from_sm(sm_client, sm_invalid_key_id) == None + out, _ = capfd.readouterr() + assert out == "Parameter \"test-invalid-secret\" is not in valid JSON format.\n" + +def test_nonexistent_secret(sm_client, capfd): + assert get_parameter_from_sm(sm_client, 'test-nonexistent-secret') == None + out, _ = capfd.readouterr() + assert out == "Parameter \"test-nonexistent-secret\" not found.\n" \ No newline at end of file diff --git a/clientv2/.eslintrc.cjs b/clientv2/.eslintrc.cjs index 1c09fd3..5e31e8d 100644 --- a/clientv2/.eslintrc.cjs +++ b/clientv2/.eslintrc.cjs @@ -6,5 +6,7 @@ module.exports = { rules: { 'react/react-in-jsx-scope': 'off', 'import/extensions': 'off', + 'no-plusplus': [2, { allowForLoopAfterthoughts: true }], + 'no-console': [2, { allow: ['warn', 'error'] }], }, }; \ No newline at end of file diff --git a/clientv2/src/Router.tsx b/clientv2/src/Router.tsx index b16902f..eefd265 100644 --- a/clientv2/src/Router.tsx +++ b/clientv2/src/Router.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, ReactNode, ErrorInfo } from 'react'; +import React, { useState, useEffect, ReactNode } from 'react'; import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; import { AuthRoleEnum, useAuth } from './components/AuthContext'; import { LoginPage } from './pages/Login.page'; @@ -84,14 +84,14 @@ const ErrorBoundary: React.FC = ({ children }) => { const [hasError, setHasError] = useState(false); const [error, setError] = useState(null); - const onError = (error: Error, errorInfo: ErrorInfo) => { + const onError = (errorObj: Error) => { setHasError(true); - setError(error); + setError(errorObj); }; useEffect(() => { const errorHandler = (event: ErrorEvent) => { - onError(event.error, { componentStack: '' }); + onError(event.error); }; window.addEventListener('error', errorHandler); @@ -104,7 +104,7 @@ const ErrorBoundary: React.FC = ({ children }) => { if (error.message === '404') { return ; } - return + return ; } return <>{children}; @@ -113,11 +113,12 @@ const ErrorBoundary: React.FC = ({ children }) => { export const Router: React.FC = () => { const { isLoggedIn, userData } = useAuth(); - const router = !isLoggedIn || !userData - ? unauthenticatedRouter - : userData.role === AuthRoleEnum.RECRUITER - ? recruiterRouter - : studentRouter; + const router = + !isLoggedIn || !userData + ? unauthenticatedRouter + : userData.role === AuthRoleEnum.RECRUITER + ? recruiterRouter + : studentRouter; return ( diff --git a/clientv2/src/components/AuthContext/index.tsx b/clientv2/src/components/AuthContext/index.tsx index bc56e3f..0860f3a 100644 --- a/clientv2/src/components/AuthContext/index.tsx +++ b/clientv2/src/components/AuthContext/index.tsx @@ -15,7 +15,6 @@ import { } from '@azure/msal-browser'; import { MantineProvider } from '@mantine/core'; import FullScreenLoader from './LoadingScreen'; -import { notifications } from '@mantine/notifications'; export enum AuthSourceEnum { MSAL, @@ -78,7 +77,9 @@ export const AuthProvider: React.FC = ({ children }) => { useEffect(() => { if (isAuthenticated && !isLoading && !userData) { - const isRecruiter = getKindePermission(`recruiter:resume-book-${import.meta.env.VITE_RUN_ENVIRONMENT}`).isGranted; + const isRecruiter = getKindePermission( + `recruiter:resume-book-${import.meta.env.VITE_RUN_ENVIRONMENT}` + ).isGranted; if (!isRecruiter) { setUserData(null); setIsLoggedIn(false); @@ -102,7 +103,7 @@ export const AuthProvider: React.FC = ({ children }) => { handleMsalResponse(response); } else if (accounts.length > 0) { // User is already logged in, set the state - const [lastName, firstName] = accounts[0].name?.split(',')!; + const [lastName, firstName] = accounts[0].name?.split(',')! || []; setUserData({ email: accounts[0].username, name: `${firstName} ${lastName}`, @@ -175,9 +176,9 @@ export const AuthProvider: React.FC = ({ children }) => { }, [userData, instance, getKindeToken]); const loginMsal = useCallback(async () => { - const accounts = instance.getAllAccounts(); - if (accounts.length > 0) { - instance.setActiveAccount(accounts[0]); + const accountsLocal = instance.getAllAccounts(); + if (accountsLocal.length > 0) { + instance.setActiveAccount(accountsLocal[0]); } else { await instance.loginRedirect(); } diff --git a/clientv2/src/components/FullPageError/index.test.tsx b/clientv2/src/components/FullPageError/index.test.tsx new file mode 100644 index 0000000..185b059 --- /dev/null +++ b/clientv2/src/components/FullPageError/index.test.tsx @@ -0,0 +1,28 @@ +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@/test-utils'; +import FullPageError from './index'; // Adjust the import path as necessary + +describe('FullPageError', () => { + it('renders with default error messages when no props are provided', () => { + render(); + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong. Please try again later.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + }); + + it('renders custom error codes and messages when provided', () => { + render(); + expect(screen.getByText('404')).toBeInTheDocument(); + expect(screen.getByText('Page not found')).toBeInTheDocument(); + }); + + it('displays a retry button when an onRetry handler is provided', async () => { + const onRetry = vi.fn(); + render(); + const retryButton = screen.getByTestId('errorRetryButton'); + expect(retryButton).toBeInTheDocument(); + await userEvent.click(retryButton); + expect(onRetry).toHaveBeenCalled(); + }); +}); diff --git a/clientv2/src/components/FullPageError/index.tsx b/clientv2/src/components/FullPageError/index.tsx index 6cf4728..6b37a52 100644 --- a/clientv2/src/components/FullPageError/index.tsx +++ b/clientv2/src/components/FullPageError/index.tsx @@ -13,7 +13,7 @@ const FullPageError: React.FC = ({ errorCode, errorMessage, {errorCode || 'An error occurred'} {errorMessage || 'Something went wrong. Please try again later.'} {onRetry && ( - )} diff --git a/clientv2/src/components/LoginComponent/index.tsx b/clientv2/src/components/LoginComponent/index.tsx index 79228ce..1200555 100644 --- a/clientv2/src/components/LoginComponent/index.tsx +++ b/clientv2/src/components/LoginComponent/index.tsx @@ -9,10 +9,10 @@ import { Anchor, Title, } from '@mantine/core'; +import { IconLock } from '@tabler/icons-react'; import { AcmLoginButton } from './AcmLoginButton'; import { PartnerLoginButton } from './PartnerLoginButton'; import brandImgUrl from '@/banner-blue.png'; -import { IconLock } from '@tabler/icons-react'; export function LoginComponent(props: PaperProps) { return ( diff --git a/clientv2/src/components/Navbar/Logo.tsx b/clientv2/src/components/Navbar/Logo.tsx index fc112fa..a33d093 100644 --- a/clientv2/src/components/Navbar/Logo.tsx +++ b/clientv2/src/components/Navbar/Logo.tsx @@ -29,7 +29,11 @@ const LogoBadge: React.FC = ({ size, linkTo, showText }) => { }} > ACM Logo - {showText ? isNonProd ? `Resume Book ${import.meta.env.VITE_RUN_ENVIRONMENT.toUpperCase()} ENV` : 'Resume Book' : null} + {showText + ? isNonProd + ? `Resume Book ${import.meta.env.VITE_RUN_ENVIRONMENT.toUpperCase()} ENV` + : 'Resume Book' + : null} ); diff --git a/clientv2/src/components/Navbar/index.tsx b/clientv2/src/components/Navbar/index.tsx index df12cf8..1a3b2cc 100644 --- a/clientv2/src/components/Navbar/index.tsx +++ b/clientv2/src/components/Navbar/index.tsx @@ -1,18 +1,16 @@ 'use client'; -import { Group, Divider, Box, Burger, Drawer, ScrollArea, rem, Badge } from '@mantine/core'; +import { Group, Divider, Box, Burger, Drawer, ScrollArea, rem } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import classes from './index.module.css'; import LogoBadge from './Logo'; -import { AuthContextData, AuthRoleEnum, AuthSourceEnum } from '../AuthContext'; +import { AuthContextData, AuthRoleEnum } from '../AuthContext'; import { AuthenticatedProfileDropdown } from '../ProfileDropdown'; interface HeaderNavbarProps { userData?: AuthContextData | null; } -const isActiveLink = (path: string) => location.pathname === path; - const HeaderNavbar: React.FC = ({ userData }) => { const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] = useDisclosure(false); return ( diff --git a/clientv2/src/components/ProfileDropdown/index.tsx b/clientv2/src/components/ProfileDropdown/index.tsx index af2c9a9..e679f81 100644 --- a/clientv2/src/components/ProfileDropdown/index.tsx +++ b/clientv2/src/components/ProfileDropdown/index.tsx @@ -17,7 +17,6 @@ import { import { IconChevronDown, IconUser, IconMail, IconBuilding } from '@tabler/icons-react'; import classes from '../Navbar/index.module.css'; import { AuthContextData, useAuth, roleToString, AuthSourceEnum } from '../AuthContext'; -import { useKindeAuth } from '@kinde-oss/kinde-auth-react'; interface ProfileDropdownProps { userData: AuthContextData; diff --git a/clientv2/src/components/ProfileViewer/GenerateProfileModal.tsx b/clientv2/src/components/ProfileViewer/GenerateProfileModal.tsx index 1401e1c..f31dd74 100644 --- a/clientv2/src/components/ProfileViewer/GenerateProfileModal.tsx +++ b/clientv2/src/components/ProfileViewer/GenerateProfileModal.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Group, Loader, Select, Text, TextInput, Title } from '@mantine/core'; +import { Alert, Button, Group, Loader, Select, Text, TextInput } from '@mantine/core'; import { IconAlertTriangle, IconSparkles } from '@tabler/icons-react'; import { useForm } from '@mantine/form'; @@ -34,13 +34,18 @@ export const GenerateProfileModal: React.FC = ({ return ( <> }> - This feature is in beta.

- By using this feature, you agree to send content from your resume to OpenAI for processing and - response generation using a large-language model (LLM). This data is subject to OpenAI's privacy and security policies. -

LLMs can make mistakes. Check important information before saving. + This feature is in beta. +
+
+ By using this feature, you agree to send content from your resume to OpenAI for processing + and response generation using a large-language model (LLM). This data is subject to + OpenAI's privacy and security policies. +
+
+ LLMs can make mistakes. Check important information before saving.
- - We'll just need some additional information from you to generate a profile. + + We'll just need some additional information from you to generate a profile.
handleFormSubmit(values))}>