From 9487ac8062bdc0ed9f837d7c06225e33dd76ebdb Mon Sep 17 00:00:00 2001 From: Jordan Wong <42422209+JorWo@users.noreply.github.com> Date: Fri, 9 Feb 2024 13:13:48 -1000 Subject: [PATCH] Setup project with layout.tsx, CAS auth, and .github (#1) --- .github/pull_request_template.md | 15 ++ .github/workflows/badges.yml | 133 +++++++++++++ .github/workflows/ci.yml | 56 ++++++ ui/.env | 14 ++ ui/.env.production | 7 + ui/.env.test | 3 + ui/.eslintrc.json | 44 +++++ ui/.gitignore | 37 ++++ ui/__mocks__/iron-session.ts | 3 + ui/__mocks__/next/headers.ts | 3 + ui/__mocks__/next/navigation.ts | 3 + ui/components.json | 17 ++ ui/jest.config.ts | 31 ++++ ui/next.config.mjs | 17 ++ ui/package.json | 59 ++++++ ui/postcss.config.js | 6 + ui/public/uh-groupings-logo-large.svg | 3 + ui/public/uh-groupings-logo.svg | 3 + ui/public/uh-logo-system.svg | 140 ++++++++++++++ ui/src/access/AuthenticationService.ts | 82 +++++++++ ui/src/access/AuthorizationService.ts | 77 ++++++++ ui/src/access/Role.ts | 8 + ui/src/access/Saml11Validator.ts | 46 +++++ ui/src/access/Session.ts | 14 ++ ui/src/access/User.ts | 21 +++ ui/src/app/(index)/page.tsx | 7 + ui/src/app/api/cas/login/route.ts | 17 ++ ui/src/app/api/cas/logout/route.ts | 13 ++ ui/src/app/favicon.ico | Bin 0 -> 15086 bytes ui/src/app/globals.css | 13 ++ ui/src/app/layout.tsx | 32 ++++ ui/src/components/layout/footer/Footer.tsx | 37 ++++ .../components/layout/navbar/LoginButton.tsx | 32 ++++ .../components/layout/navbar/MobileNavbar.tsx | 59 ++++++ ui/src/components/layout/navbar/NavLinks.ts | 29 +++ ui/src/components/layout/navbar/Navbar.tsx | 52 ++++++ ui/src/components/ui/button.tsx | 58 ++++++ ui/src/components/ui/sheet.tsx | 174 ++++++++++++++++++ ui/src/components/ui/utils.ts | 6 + ui/src/middleware.ts | 31 ++++ ui/tailwind.config.ts | 60 ++++++ ui/tests/access/AuthenticationService.test.ts | 98 ++++++++++ ui/tests/access/AuthorizationService.test.ts | 93 ++++++++++ ui/tests/access/Role.test.ts | 11 ++ ui/tests/access/Saml11Validator.test.ts | 31 ++++ ui/tests/access/User.test.ts | 17 ++ ui/tests/app/api/cas/login/route.test.ts | 24 +++ ui/tests/app/api/cas/logout/route.test.ts | 21 +++ .../components/layout/footer/Footer.test.tsx | 17 ++ .../layout/navbar/LoginButton.test.tsx | 53 ++++++ .../layout/navbar/MobileNavbar.test.tsx | 86 +++++++++ .../components/layout/navbar/Navbar.test.tsx | 92 +++++++++ ui/tests/middleware.test.tsx | 107 +++++++++++ ui/tests/setupJest.tsx | 26 +++ ui/tsconfig.json | 26 +++ ...pings-ui-3-0-overrides.skeleton.properties | 7 + 56 files changed, 2171 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/badges.yml create mode 100644 .github/workflows/ci.yml create mode 100644 ui/.env create mode 100644 ui/.env.production create mode 100644 ui/.env.test create mode 100644 ui/.eslintrc.json create mode 100644 ui/.gitignore create mode 100644 ui/__mocks__/iron-session.ts create mode 100644 ui/__mocks__/next/headers.ts create mode 100644 ui/__mocks__/next/navigation.ts create mode 100644 ui/components.json create mode 100644 ui/jest.config.ts create mode 100644 ui/next.config.mjs create mode 100644 ui/package.json create mode 100644 ui/postcss.config.js create mode 100644 ui/public/uh-groupings-logo-large.svg create mode 100644 ui/public/uh-groupings-logo.svg create mode 100644 ui/public/uh-logo-system.svg create mode 100644 ui/src/access/AuthenticationService.ts create mode 100644 ui/src/access/AuthorizationService.ts create mode 100644 ui/src/access/Role.ts create mode 100644 ui/src/access/Saml11Validator.ts create mode 100644 ui/src/access/Session.ts create mode 100644 ui/src/access/User.ts create mode 100644 ui/src/app/(index)/page.tsx create mode 100644 ui/src/app/api/cas/login/route.ts create mode 100644 ui/src/app/api/cas/logout/route.ts create mode 100644 ui/src/app/favicon.ico create mode 100644 ui/src/app/globals.css create mode 100644 ui/src/app/layout.tsx create mode 100644 ui/src/components/layout/footer/Footer.tsx create mode 100644 ui/src/components/layout/navbar/LoginButton.tsx create mode 100644 ui/src/components/layout/navbar/MobileNavbar.tsx create mode 100644 ui/src/components/layout/navbar/NavLinks.ts create mode 100644 ui/src/components/layout/navbar/Navbar.tsx create mode 100644 ui/src/components/ui/button.tsx create mode 100644 ui/src/components/ui/sheet.tsx create mode 100644 ui/src/components/ui/utils.ts create mode 100644 ui/src/middleware.ts create mode 100644 ui/tailwind.config.ts create mode 100644 ui/tests/access/AuthenticationService.test.ts create mode 100644 ui/tests/access/AuthorizationService.test.ts create mode 100644 ui/tests/access/Role.test.ts create mode 100644 ui/tests/access/Saml11Validator.test.ts create mode 100644 ui/tests/access/User.test.ts create mode 100644 ui/tests/app/api/cas/login/route.test.ts create mode 100644 ui/tests/app/api/cas/logout/route.test.ts create mode 100644 ui/tests/components/layout/footer/Footer.test.tsx create mode 100644 ui/tests/components/layout/navbar/LoginButton.test.tsx create mode 100644 ui/tests/components/layout/navbar/MobileNavbar.test.tsx create mode 100644 ui/tests/components/layout/navbar/Navbar.test.tsx create mode 100644 ui/tests/middleware.test.tsx create mode 100644 ui/tests/setupJest.tsx create mode 100644 ui/tsconfig.json create mode 100644 ui/uh-groupings-ui-3-0-overrides.skeleton.properties diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5a0e996b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +# Ticket Link + +[Ticket](Insert Link Here) + +# List of squashed commits + +- Commit 1 +- Commit 2 +- Commit 3 + +# Test Checklist + +- [ ] Unit Tests Passed +- [ ] Integration Tests Passed +- [ ] General Visual Inspection diff --git a/.github/workflows/badges.yml b/.github/workflows/badges.yml new file mode 100644 index 00000000..9733087e --- /dev/null +++ b/.github/workflows/badges.yml @@ -0,0 +1,133 @@ +name: Generate badges + +on: + push: + branches: [ main ] + +jobs: + create-branch: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Git + run: | + git config --global user.name 'Groupings Project' + git config --global user.email 'actions@noreply.its.hawaii.edu' + + - name: Create the badges branch if it doesn't exist + run: | + branch_name="badges" + if ! git ls-remote --exit-code origin refs/heads/$branch_name; then + echo "Branch $branch_name does not exist. Creating it..." + git checkout --orphan $branch_name + git rm -rf . + git commit --allow-empty -m "Create $branch_name branch" + git push -u origin $branch_name + else + echo "Branch $branch_name already exists." + fi + + generate-jest-badges: + needs: create-branch + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 16.x + + - name: Installing dependencies + run: | + cd ui + npm install + + - name: Tests + run: | + cd ui + npm run test:coverage + + - name: Switch to badges branch + run: | + git fetch + git switch badges + + - name: Generating coverage badges + uses: jpb06/jest-badges-action@latest + with: + branches: '*' + coverage-summary-path: ui/coverage/coverage-summary.json + no-commit: true + target-branch: badges + + - name: Commit and push badges + run: | + cd badges + if [[ `git status --porcelain *.svg` ]]; then + git config --global user.name 'Groupings Project' + git config --global user.email 'actions@noreply.its.hawaii.edu' + git add *.svg + git commit -m "Autogenerated Jest badges" *.svg + git push origin badges -f + fi + + # Re-enable once UH Groupings API is added to this repo + # generate-jacoco-badges: + # needs: create-branch + # runs-on: ubuntu-latest + # steps: + # - name: Checkout repository + # uses: actions/checkout@v3 + # with: + # fetch-depth: 0 + + # - name: Set up JDK 17 + # uses: actions/setup-java@v1 + # with: + # java-version: 17 + + # - name: Build with Maven and Generate JaCoCo Report + # run: | + # mvn clean test jacoco:report -f api/pom.xml -D'logging.level.edu.hawaii.its.holiday=OFF' -D'logging.level.org.springframework=ERROR' -D'spring.main.banner-mode=off' + # mv api/target/ target/ + + # - name: Switch to badges branch + # run: | + # git fetch + # git switch badges + + # - name: Generate Jacoco Badge + # id: jacoco + # uses: cicirello/jacoco-badge-generator@v2 + # with: + # coverage-label: junit coverage + # badges-directory: badges + # generate-branches-badge: true + + # - name: Log coverage percentage + # run: | + # echo "coverage = ${{ steps.jacoco.outputs.coverage }}" + + # - name: Commit and push + # if: ${{ github.event_name != 'pull_request' }} + # run: | + # cd badges + # if [[ `git status --porcelain *.svg` ]]; then + # git config --global user.name 'Groupings Project' + # git config --global user.email 'actions@noreply.its.hawaii.edu' + # git add *.svg + # git commit -m "Autogenerated JaCoCo coverage badge" *.svg + # git push origin badges -f + # fi + + # - name: Upload Jacoco coverage report + # uses: actions/upload-artifact@v2 + # with: + # name: jacoco-report + # path: target/site/jacoco/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..22db033a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + # Re-enable once UH Groupings API is added to this repo + # junit: + # runs-on: ubuntu-latest + + # steps: + # - name: Checkout code + # uses: actions/checkout@v3 + + # - name: Set up JDK 17 + # uses: actions/setup-java@v1 + # with: + # java-version: 17 + + # - name: Build with Maven + # run: | + # cd api + # mvn clean test + + jest: + strategy: + matrix: + node-version: [ 18.x, 20.x ] + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: | + cd ui + npm install + + - name: Run ESLint + run: | + cd ui + npm run lint + + - name: Run Jest Tests + run: | + cd ui + npm run test diff --git a/ui/.env b/ui/.env new file mode 100644 index 00000000..64641bc9 --- /dev/null +++ b/ui/.env @@ -0,0 +1,14 @@ +# ========================================================================= +NEXT_PUBLIC_BASE_URL=http://localhost:8080/uhgroupings +NEXT_PUBLIC_API_2_1_BASE_URL=http://localhost:8081/uhgroupingsapi/api/groupings/v2.1 + +# ========================================================================= +# CAS. +NEXT_PUBLIC_CAS_URL=https://cas-test.its.hawaii.edu/cas +NEXT_PUBLIC_SAML_REQUEST_TEMPLATE=%s + +# ========================================================================= +# Test Properties. +TEST_USER_A={ "name": "Testf-iwt-a TestIAM-staff", "firstName": "Testf-iwt-a", "lastName": "TestIAM-staff", "uid": "testiwta", "uhUuid": "99997010", "roles": [] } +XML_SOAP_RESPONSE=https://myserver.example.edu/myappiam_0108urn:oasis:names:tc:SAML:1.0:cm:artifacttestiwtaurn:oasis:names:tc:SAML:1.0:cm:artifacttestiwta@hawaii.edustaff99997010Testf-iwt-auhsystemTestf-iwt-a TestIAM-stafftestiwtatestiwta@hawaii.edutestiwta@hawaii.eduTestIAM-staffeduPersonOrgDN=uhsystem,eduPersonAffiliation=staff +IRON_SESSION_SECRET=IronSessionSecretForTestingAuthentication diff --git a/ui/.env.production b/ui/.env.production new file mode 100644 index 00000000..be786180 --- /dev/null +++ b/ui/.env.production @@ -0,0 +1,7 @@ +# ========================================================================= +BASE_URL=https://www.hawaii.edu/its/uhgroupings +API_2_1_BASE_URL=http://iws04.pvt.hawaii.edu:8080/uhgroupingsapi/api/groupings/v2.1 + +# ========================================================================= +# CAS. +CAS_URL=https://authn.hawaii.edu/cas diff --git a/ui/.env.test b/ui/.env.test new file mode 100644 index 00000000..e30571ad --- /dev/null +++ b/ui/.env.test @@ -0,0 +1,3 @@ +# ========================================================================= +BASE_URL=https://www.test.hawaii.edu/uhgroupings +API_2_1_BASE_URL=http://iws74.pvt.hawaii.edu:8080/uhgroupingsapi/api/groupings/v2.1 diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 00000000..c0ce4add --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "next/core-web-vitals" + ], + "overrides": [ + { + "files": ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], + "extends": ["plugin:testing-library/react"] + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "react", + "@typescript-eslint", + "@stylistic", + "eslint-plugin-tsdoc" + ], + "rules": { + "@stylistic/max-len": ["error", { "code": 120 }], + "@stylistic/indent": ["error", 4], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/quotes": ["error", "single", { "allowTemplateLiterals": true }], + "@stylistic/arrow-parens": "off", + "@stylistic/linebreak-style": "off", + "react/function-component-definition": [2, { "namedComponents": "arrow-function" }], + "tsdoc/syntax": "warn", + "func-names": "off", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-return-assign": "off", + "prefer-arrow-callback": "off" + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..6d333829 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz +package-lock.json + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/__mocks__/iron-session.ts b/ui/__mocks__/iron-session.ts new file mode 100644 index 00000000..5c4f3b86 --- /dev/null +++ b/ui/__mocks__/iron-session.ts @@ -0,0 +1,3 @@ +const ironSession = jest.genMockFromModule('iron-session'); + +module.exports = ironSession; diff --git a/ui/__mocks__/next/headers.ts b/ui/__mocks__/next/headers.ts new file mode 100644 index 00000000..b70d1a94 --- /dev/null +++ b/ui/__mocks__/next/headers.ts @@ -0,0 +1,3 @@ +const nextHeaders = jest.genMockFromModule('next/headers'); + +module.exports = nextHeaders; diff --git a/ui/__mocks__/next/navigation.ts b/ui/__mocks__/next/navigation.ts new file mode 100644 index 00000000..00652f92 --- /dev/null +++ b/ui/__mocks__/next/navigation.ts @@ -0,0 +1,3 @@ +const nextNavigation = jest.genMockFromModule('next/navigation'); + +module.exports = nextNavigation; diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..ea229572 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/components/ui/utils" + } +} diff --git a/ui/jest.config.ts b/ui/jest.config.ts new file mode 100644 index 00000000..b21da05b --- /dev/null +++ b/ui/jest.config.ts @@ -0,0 +1,31 @@ +import type { Config } from 'jest' +import nextJest from 'next/jest.js' + +const createJestConfig = nextJest({ + dir: './', +}); + +const config: Config = { + clearMocks: true, + collectCoverageFrom: [ + './src/**/*.ts*', + ], + coveragePathIgnorePatterns: [ + './src/components/ui', // Ignore shadcn/ui components + ], + coverageReporters: ['json-summary', 'text', 'html'], + testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: [] + }, + setupFilesAfterEnv: [ + '/tests/setupJest.tsx' + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleDirectories: ['node_modules', ''], + moduleNameMapper: { + "^@/(.*)$": "/src/$1" + } +}; + +export default createJestConfig(config); diff --git a/ui/next.config.mjs b/ui/next.config.mjs new file mode 100644 index 00000000..79f8b2b6 --- /dev/null +++ b/ui/next.config.mjs @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ + +import dotenv from 'dotenv'; +import os from 'os'; + +// Define path to overrides file +// TODO: Rename uh-groupings-ui-3-0-overrides to uh-groupings-ui-overrides once 3.0 is complete +dotenv.config({path: `${os.homedir()}/.${os.userInfo().username}-conf/uh-groupings-ui-3-0-overrides.properties`}) + +const nextConfig = { + basePath: '/uhgroupings', + experimental: { + serverComponentsExternalPackages: ['camaro'] + } +}; + +export default nextConfig; diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..823844cb --- /dev/null +++ b/ui/package.json @@ -0,0 +1,59 @@ +{ + "name": "uh-groupings", + "version": "3.0", + "private": true, + "scripts": { + "dev": "next dev -p 8080", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", + "camaro": "^6.2.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "dotenv": "^16.4.1", + "iron-session": "^8.0.1", + "lucide-react": "^0.321.0", + "next": "14.1.0", + "react": "^18", + "react-dom": "^18", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "uniqid": "^5.4.0" + }, + "devDependencies": { + "@stylistic/eslint-plugin": "^1.6.1", + "@swc/core": "^1.3.106", + "@testing-library/jest-dom": "^6.3.0", + "@testing-library/react": "^14.1.2", + "@types/jest": "^29.5.11", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/uniqid": "^5.3.4", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "autoprefixer": "^10.0.1", + "axios-mock-adapter": "^1.22.0", + "eslint": "^8", + "eslint-config-next": "14.1.0", + "eslint-plugin-testing-library": "^6.2.0", + "eslint-plugin-tsdoc": "^0.2.17", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "postcss": "^8.4.33", + "postcss-preset-mantine": "^1.12.3", + "postcss-simple-vars": "^7.0.1", + "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 00000000..21312be2 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + 'tailwindcss': {}, + 'autoprefixer': {}, + }, +}; diff --git a/ui/public/uh-groupings-logo-large.svg b/ui/public/uh-groupings-logo-large.svg new file mode 100644 index 00000000..34f84fcb --- /dev/null +++ b/ui/public/uh-groupings-logo-large.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/uh-groupings-logo.svg b/ui/public/uh-groupings-logo.svg new file mode 100644 index 00000000..dc04b50e --- /dev/null +++ b/ui/public/uh-groupings-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/uh-logo-system.svg b/ui/public/uh-logo-system.svg new file mode 100644 index 00000000..8d78b359 --- /dev/null +++ b/ui/public/uh-logo-system.svg @@ -0,0 +1,140 @@ + +image/svg+xml diff --git a/ui/src/access/AuthenticationService.ts b/ui/src/access/AuthenticationService.ts new file mode 100644 index 00000000..0ab1d854 --- /dev/null +++ b/ui/src/access/AuthenticationService.ts @@ -0,0 +1,82 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { IronSession, getIronSession } from 'iron-session'; +import { SessionData, SessionOptions } from './Session'; +import User, { AnonymousUser } from './User'; +import { validateTicket } from './Saml11Validator'; +import { setRoles } from './AuthorizationService'; +import { isDeepStrictEqual } from 'util'; + +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Gets the current logged-in user. + * + * @returns The current user + */ +export const getCurrentUser = async (): Promise => { + const session = await getSession(); + if (!session.user) { + return AnonymousUser; + } + return session.user; +} + +/** + * Redirects the user to the CAS login. + */ +export const login = (): void => { + redirect(`${casUrl}/login?service=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`); +} + +/** + * Redirects the user to the CAS logout. + */ +export const logout = (): void => { + redirect(`${casUrl}/logout?service=${encodeURIComponent(`${baseUrl}/api/cas/logout`)}`); +} + +/** + * Validates ticket after successful CAS login, sets their roles, then saves the user to the session. + * + * @remarks + * This function is primarily used in the /api/cas/login API endpoint to catch the redirect + * after successfully logging in through CAS. + * + * @param ticket - The ticket returned from successful CAS login + */ +export const handleLogin = async (ticket: string): Promise => { + const user = await validateTicket(ticket); + if (isDeepStrictEqual(user, AnonymousUser)) { + return; + } + await setRoles(user); + + const session = await getSession(); + session.user = user; + await session.save(); +}; + +/** + * Removes the user from the session, therby logging them out. + * + * @remarks + * This function is primarly used in the /api/cas/logout API endpoint to catch the redirect + * after successfully logging out through CAS. + */ +export const handleLogout = async (): Promise => { + const session = await getSession(); + session.destroy(); +} + +/** + * Get the session data containing the current user stored in Iron Session. + * + * @returns The session containing the current user + */ +const getSession = async (): Promise> => { + return await getIronSession(cookies(), SessionOptions); +} diff --git a/ui/src/access/AuthorizationService.ts b/ui/src/access/AuthorizationService.ts new file mode 100644 index 00000000..6e868f1a --- /dev/null +++ b/ui/src/access/AuthorizationService.ts @@ -0,0 +1,77 @@ +'use server'; + +import User from './User'; +import Role from './Role'; +import axios from 'axios'; + +const apiBaseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string; + +/** + * Sets the appropriate roles for a user. + * + * @param user - The user + */ +export const setRoles = async (user: User): Promise => { + // All users should have ANONYMOUS role to describe universal access (e.g. /about page in NavLinks.ts) + user.roles.push(Role.ANONYMOUS); + + if (isValidUhUuid(user.uhUuid)) { + user.roles.push(Role.UH); + } + if (await isOwner(user.uhUuid)) { + user.roles.push(Role.OWNER); + } + if (await isAdmin(user.uhUuid)) { + user.roles.push(Role.ADMIN); + } +} + +/** + * Calls UH Groupings API to check if the uhIdentifier is an owner. + * + * @param uhIdentifier - The uid or uhUuid + * + * @returns True if the uhIdentifier is an owner of a grouping + */ +const isOwner = async (uhIdentifier: string): Promise => { + try { + const { data } = await axios.get(`${apiBaseUrl}/owners`, { + headers: { 'current_user': uhIdentifier } + }); + return data; + } catch (error) { + console.error(error); + } + return false; +} + +/** + * Calls UH Groupings API to check if the uhIdentifier is an admin. + * + * @param uhIdentifier - The uid or uhUuid + * + * @returns True if the uhIdentifier is an admin + */ +const isAdmin = async (uhIdentifier: string): Promise => { + try { + const { data } = await axios.get(`${apiBaseUrl}/admins`, { + headers: { 'current_user': uhIdentifier } + }); + return data; + } catch (error) { + console.error(error); + } + return false; +} + +/** + * Checks if uhUuid is valid using Regex. + * + * @param uhUuid - 8 digit unique user indentifier + * + * @returns True if uhUuid is valid + */ +const isValidUhUuid = (uhUuid: string): boolean => { + const uhUuidPattern = new RegExp(/^[0-9]{8}$/); + return uhUuidPattern.test(uhUuid); +} diff --git a/ui/src/access/Role.ts b/ui/src/access/Role.ts new file mode 100644 index 00000000..58c32d8f --- /dev/null +++ b/ui/src/access/Role.ts @@ -0,0 +1,8 @@ +enum Role { + ADMIN = 'ADMIN', + ANONYMOUS = 'ANONYMOUS', + OWNER = 'OWNER', + UH = 'UH' +} + +export default Role; diff --git a/ui/src/access/Saml11Validator.ts b/ui/src/access/Saml11Validator.ts new file mode 100644 index 00000000..1887ead0 --- /dev/null +++ b/ui/src/access/Saml11Validator.ts @@ -0,0 +1,46 @@ +import User, { AnonymousUser } from './User'; +import uniqid from 'uniqid'; +import { format } from 'util'; +import { transform } from 'camaro'; +import axios from 'axios'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const samlRequestTemplate = process.env.NEXT_PUBLIC_SAML_REQUEST_TEMPLATE as string; + +/** + * Validates ticket by calling the /samlValidate endpoint of CAS server with the ticket formatted as a SAML. + * Then the CAS server responds with a SAML containing the user, which is then transformed into the User type. + * + * @param ticket - The ticket returned from successful CAS login + * + * @returns The user from the ticket or an AnonymousUser if error occurs + */ +export const validateTicket = async (ticket: string): Promise => { + const samlValidateUrl = `${casUrl}/samlValidate?TARGET=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`; + const samlResponseTemplate = { + name: '//*[local-name() = "Attribute"][@AttributeName="cn"]', + firstName: '//*[local-name() = "Attribute"][@AttributeName="givenName"]', + lastName: '//*[local-name() = "Attribute"][@AttributeName="sn"]', + uid: '//*[local-name() = "Attribute"][@AttributeName="uid"]', + uhUuid: '//*[local-name() = "Attribute"][@AttributeName="uhUuid"]', + }; + + const currentDate = new Date().toISOString(); + const samlRequestBody = format(samlRequestTemplate, `${uniqid()}.${currentDate}`, currentDate, ticket); + + try { + const response = await axios.post(samlValidateUrl, samlRequestBody, { + headers: { 'Content-Type': 'text/xml' } + }); + const casUser = await transform(response.data, samlResponseTemplate); + + return { + ...casUser, + roles: [] + } as User; + + } catch (error) { + return AnonymousUser; + } +} diff --git a/ui/src/access/Session.ts b/ui/src/access/Session.ts new file mode 100644 index 00000000..9f37f9c3 --- /dev/null +++ b/ui/src/access/Session.ts @@ -0,0 +1,14 @@ +import User from './User'; + +export interface SessionData { + user: User; +} + +export const SessionOptions = { + cookieName: 'SESSIONID', + password: process.env.IRON_SESSION_SECRET as string, + cookieOptions: { + maxAge: undefined, + secure: process.env.NODE_ENV === 'production', + }, +}; diff --git a/ui/src/access/User.ts b/ui/src/access/User.ts new file mode 100644 index 00000000..71d1fea7 --- /dev/null +++ b/ui/src/access/User.ts @@ -0,0 +1,21 @@ +import Role from './Role'; + +type User = { + name: string, + firstName: string, + lastName: string, + uid: string, + uhUuid: string, + roles: Role[] +} + +export const AnonymousUser: User = { + name: '', + firstName: '', + lastName: '', + uid: '', + uhUuid: '', + roles: [Role.ANONYMOUS] as const +} + +export default User; diff --git a/ui/src/app/(index)/page.tsx b/ui/src/app/(index)/page.tsx new file mode 100644 index 00000000..b0223723 --- /dev/null +++ b/ui/src/app/(index)/page.tsx @@ -0,0 +1,7 @@ +const Home = () => { + return ( + null + ); +} + +export default Home; diff --git a/ui/src/app/api/cas/login/route.ts b/ui/src/app/api/cas/login/route.ts new file mode 100644 index 00000000..e1649eaf --- /dev/null +++ b/ui/src/app/api/cas/login/route.ts @@ -0,0 +1,17 @@ +import { redirect } from 'next/navigation'; +import type { NextRequest } from 'next/server'; +import { handleLogin } from '@/access/AuthenticationService'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Next.js route handler to catch the redirect after successfully logging in through CAS. + * Handles the login then redirects the user back to the home page. + * + * @param req - The request object + */ +export const GET = async (req: NextRequest) => { + const ticket = req.nextUrl.searchParams.get('ticket') as string; + await handleLogin(ticket); + redirect(baseUrl); +} diff --git a/ui/src/app/api/cas/logout/route.ts b/ui/src/app/api/cas/logout/route.ts new file mode 100644 index 00000000..2d7b4681 --- /dev/null +++ b/ui/src/app/api/cas/logout/route.ts @@ -0,0 +1,13 @@ +import { redirect } from 'next/navigation'; +import { handleLogout } from '@/access/AuthenticationService'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Next.js route handler to catch the redirect after successfully logging out through CAS. + * Handles the logout then redirects the user back to the home page. + */ +export const GET = async () => { + await handleLogout(); + redirect(baseUrl); +} diff --git a/ui/src/app/favicon.ico b/ui/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..56fe42c4d6268f83ee15d75489c9ad257bcea99e GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css new file mode 100644 index 00000000..450c478b --- /dev/null +++ b/ui/src/app/globals.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + font-family: var(--font-source-sans-3), 'Helvetica', Arial, sans-serif; + } + + nav { + font-family: var(--font-source-sans-3), 'Helvetica', Arial, sans-serif; + } +} diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx new file mode 100644 index 00000000..89dd57ab --- /dev/null +++ b/ui/src/app/layout.tsx @@ -0,0 +1,32 @@ +import './globals.css'; +import { Source_Sans_3 } from 'next/font/google'; +import Footer from '@/components/layout/footer/Footer'; +import type { Metadata } from 'next'; +import Navbar from '@/components/layout/navbar/Navbar'; + +const sourceSans3 = Source_Sans_3({ + subsets: ['latin'], + weight: ['400', '600', '700', '900'], + style: ['normal', 'italic'], + variable: '--font-source-sans-3' +}); + +export const metadata: Metadata = { + title: 'UH Groupings' +}; + +const RootLayout = ({ + children, +}: Readonly<{ + children?: React.ReactNode; +}>) => ( + + + + {children} + + + +); + +export default RootLayout; diff --git a/ui/src/components/layout/footer/Footer.tsx b/ui/src/components/layout/footer/Footer.tsx new file mode 100644 index 00000000..e367f64d --- /dev/null +++ b/ui/src/components/layout/footer/Footer.tsx @@ -0,0 +1,37 @@ +import Image from 'next/image'; + +const Footer = () => ( + +); + +export default Footer; diff --git a/ui/src/components/layout/navbar/LoginButton.tsx b/ui/src/components/layout/navbar/LoginButton.tsx new file mode 100644 index 00000000..0059c6a0 --- /dev/null +++ b/ui/src/components/layout/navbar/LoginButton.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Role from '@/access/Role'; +import User from '@/access/User'; +import { login, logout } from '@/access/AuthenticationService'; +import { LogInIcon, LogOutIcon } from 'lucide-react'; + +const LoginButton = ({ + currentUser +}: { + currentUser: User; +}) => ( + <> + {!currentUser.roles.includes(Role.UH) ? ( + login()}> + Login + + ) : ( + logout()}> + Logout + ({currentUser.uid}) + + )} + > +); + +export default LoginButton; diff --git a/ui/src/components/layout/navbar/MobileNavbar.tsx b/ui/src/components/layout/navbar/MobileNavbar.tsx new file mode 100644 index 00000000..3fe018f8 --- /dev/null +++ b/ui/src/components/layout/navbar/MobileNavbar.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { + Sheet, + SheetContent, + SheetTrigger, +} from '@/components/ui/sheet' +import User from '@/access/User'; +import Link from 'next/link'; +import { NavLinks } from './NavLinks'; +import { useState } from 'react'; +import Role from '@/access/Role'; + +const MobileNavbar = ({ + currentUser +} : { + currentUser: User +}) => { + const [isOpen, setIsOpen] = useState(false); + const handleClick = () => { + setIsOpen(!isOpen); + }; + + return ( + + + + + + + + + + + + {NavLinks + .filter(navLink => + currentUser.roles.includes(Role.ADMIN) || currentUser.roles.includes(navLink.role)) + .map(navLink => + + {navLink.name} + )} + + + + ); +}; + + +export default MobileNavbar; diff --git a/ui/src/components/layout/navbar/NavLinks.ts b/ui/src/components/layout/navbar/NavLinks.ts new file mode 100644 index 00000000..ad29ae8e --- /dev/null +++ b/ui/src/components/layout/navbar/NavLinks.ts @@ -0,0 +1,29 @@ +import Role from '@/access/Role'; + +export const NavLinks = [ + { + name: 'Admin', + link: '/admin', + role: Role.ADMIN + }, + { + name: 'Memberships', + link: '/memberships', + role: Role.UH + }, + { + name: 'Groupings', + link: '/groupings', + role: Role.OWNER + }, + { + name: 'About', + link: '/about', + role: Role.ANONYMOUS + }, + { + name: 'Feedback', + link: '/feedback', + role: Role.UH + } +]; diff --git a/ui/src/components/layout/navbar/Navbar.tsx b/ui/src/components/layout/navbar/Navbar.tsx new file mode 100644 index 00000000..197f112a --- /dev/null +++ b/ui/src/components/layout/navbar/Navbar.tsx @@ -0,0 +1,52 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import LoginButton from './LoginButton'; +import { getCurrentUser } from '@/access/AuthenticationService'; +import MobileNavbar from './MobileNavbar'; +import { NavLinks } from './NavLinks'; +import Role from '@/access/Role'; + +const Navbar = async () => { + const currentUser = await getCurrentUser(); + + return ( + + + + + + + + + + + + + + {NavLinks + .filter(navLink => + currentUser.roles.includes(Role.ADMIN) || currentUser.roles.includes(navLink.role)) + .map(navLink => + + {navLink.name} + )} + + + + + + ); +} + +export default Navbar; diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx new file mode 100644 index 00000000..3c469dd0 --- /dev/null +++ b/ui/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/components/ui/utils' + +const buttonVariants = cva( + `inline-flex items-center justify-center whitespace-nowrap rounded-[0.25rem] text-base font-normal ring-offset-white + transition-colors ease-in-out duration-150 focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 + dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300`, + { + variants: { + variant: { + default: `bg-[#5a9cb4] hover:bg-green-blue [text-shadow:_0_1px_1px_#444] text-slate-50`, + destructive: `bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 + dark:hover:bg-red-900/90`, + outline: `border border-green-blue bg-white hover:bg-green-blue hover:text-white text-uh-teal`, + secondary: `bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 + dark:hover:bg-slate-800/80`, + ghost: `hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50`, + link: `text-slate-900 underline-offset-4 hover:underline dark:text-slate-50`, + }, + size: { + default: 'h-10 px-2.5 py-2', + sm: 'h-9 rounded-md px-2', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx new file mode 100644 index 00000000..3b2131bc --- /dev/null +++ b/ui/src/components/ui/sheet.tsx @@ -0,0 +1,174 @@ +'use client' + +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { cva, type VariantProps } from 'class-variance-authority' +import { X } from 'lucide-react' + +import { cn } from '@/components/ui/utils' + +const Sheet = SheetPrimitive.Root; + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +interface SheetTriggerProps + extends React.ComponentPropsWithoutRef { + onClick?: () => void +} +const SheetTrigger = React.forwardRef< + React.ElementRef, + SheetTriggerProps +>(({ onClick, className, children, ...props }, ref) => ( + + {children} + +)); +SheetTrigger.displayName = SheetPrimitive.Trigger.displayName; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + `fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in + data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 + dark:bg-slate-950`, + { + variants: { + side: { + top: `inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top + data-[state=open]:slide-in-from-top`, + bottom: `inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom + data-[state=open]:slide-in-from-bottom`, + left: `inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left + data-[state=open]:slide-in-from-left sm:max-w-sm`, + right: `inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right + data-[state=open]:slide-in-from-right sm:max-w-sm`, + } + }, + defaultVariants: { + side: 'right', + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps { + onClickOutside?: () => void, + hasCloseButton?: boolean +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', hasCloseButton = false, onClickOutside, className, children, ...props }, ref) => ( + + + { + e.preventDefault(); + }} + > + {children} + + {hasCloseButton && ( + + + Close + + )} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/ui/src/components/ui/utils.ts b/ui/src/components/ui/utils.ts new file mode 100644 index 00000000..518ea424 --- /dev/null +++ b/ui/src/components/ui/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export const cn = (...inputs: ClassValue[]) => { + return twMerge(clsx(inputs)) +} diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts new file mode 100644 index 00000000..4a3676ce --- /dev/null +++ b/ui/src/middleware.ts @@ -0,0 +1,31 @@ +import { cookies } from 'next/headers'; +import { NextResponse, NextRequest } from 'next/server'; +import { getIronSession } from 'iron-session'; +import { SessionData, SessionOptions } from './access/Session'; +import Role from './access/Role'; +import { isDeepStrictEqual } from 'util'; +import { AnonymousUser } from './access/User'; + +/** + * Next.js middleware function that is called upon visiting a route that matches the config. + * + * @param req - The request object + */ +export const middleware = async (req: NextRequest) => { + const session = await getIronSession(cookies(), SessionOptions); + + const { user } = session; + if (!user || isDeepStrictEqual(user, AnonymousUser)) { + return NextResponse.redirect(new URL('/uhgroupings', req.url)); + } + if (req.url.endsWith('/admin') && !user.roles.includes(Role.ADMIN)) { + return NextResponse.redirect(new URL('/uhgroupings', req.url)); + } + if (req.url.endsWith('/groupings') && !(user.roles.includes(Role.OWNER) || user.roles.includes(Role.ADMIN))) { + return NextResponse.redirect(new URL('/uhgroupings', req.url)); + } +}; + +export const config = { + matcher: ['/admin', '/memberships', '/groupings', '/feedback'] +}; diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts new file mode 100644 index 00000000..2bed1a01 --- /dev/null +++ b/ui/tailwind.config.ts @@ -0,0 +1,60 @@ +import type { Config } from 'tailwindcss' + +const config = { + darkMode: ['class'], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}' + ], + theme: { + container: { + center: true, + padding: { + 'DEFAULT': '0.75rem', + 'sm': '1rem', + 'md': '1.25rem', + 'lg': '2rem', + 'xl': '3rem', + '2xl': '9rem' + } + }, + extend: { + colors: { + 'green-blue': '#004e59', + 'uh-black': '#212121', + 'uh-teal': '#0d7078' + }, + fontFamily: { + 'source-sans-3': ['var(--font-source-sans-3)', 'Helvetica', 'Arial', 'sans-serif'], + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + screens: { + 'sm': '576px', + 'md': '768px', + 'lg': '992px', + 'xl': '1200px', + '2xl': "1400px" + } + }, + plugins: [ + require('tailwindcss-animate') + ], +} satisfies Config + +export default config; diff --git a/ui/tests/access/AuthenticationService.test.ts b/ui/tests/access/AuthenticationService.test.ts new file mode 100644 index 00000000..2fc9b537 --- /dev/null +++ b/ui/tests/access/AuthenticationService.test.ts @@ -0,0 +1,98 @@ +import { getCurrentUser, login, logout, handleLogin, handleLogout } from '@/access/AuthenticationService'; +import { createMockSession } from '../setupJest'; +import User, { AnonymousUser } from '@/access/User'; +import { redirect } from 'next/navigation'; +import IronSession from 'iron-session'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); +const xmlSoapResponse = process.env.XML_SOAP_RESPONSE as string; + +describe('AuthenticationService', () => { + + describe('getCurrentUser', () => { + + it('should return the currentUser', async () => { + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(testUser)); + + expect(await getCurrentUser()).toEqual(testUser); + }); + + it('should return an AnonymousUser when nothing is stored in the session', async () => { + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(undefined)); + + expect(await getCurrentUser()).toEqual(AnonymousUser); + }); + + }); + + describe('login', () => { + + it('should visit the CAS login url', () => { + const casLoginUrl = `${casUrl}/login?service=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`; + login(); + expect(redirect).toHaveBeenCalledWith(casLoginUrl); + }); + + }); + + describe('logout', () => { + + it('should visit the CAS logout url', async () => { + const casLogoutUrl = `${casUrl}/logout?service=${encodeURIComponent(`${baseUrl}/api/cas/logout`)}`; + logout(); + expect(redirect).toHaveBeenCalledWith(casLogoutUrl); + }); + + }); + + describe('handleLogin', () => { + + let axiosMock: MockAdapter; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + it('should return when ticket to validate is invalid', async () =>{ + const getIronSessionSpy = jest.spyOn(IronSession, 'getIronSession'); + + axiosMock.onPost().reply(500); + await handleLogin('ticket'); + + expect(getIronSessionSpy).not.toHaveBeenCalled(); + }); + + it('should save the user to the session', async () => { + const session = createMockSession(testUser); + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(session); + const sessionSaveSpy = jest.spyOn(session, 'save'); + + axiosMock + .onPost().reply(200, xmlSoapResponse) + .onGet().reply(200, false); + await handleLogin('ticket'); + + expect(sessionSaveSpy).toHaveBeenCalled(); + }); + + }); + + describe('handleLogout', () => { + + it('should destroy the session', async () => { + const session = createMockSession(testUser); + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(session); + const sessionDestroySpy = jest.spyOn(session, 'destroy'); + + await handleLogout(); + + expect(sessionDestroySpy).toHaveBeenCalled(); + }); + + }); + +}); diff --git a/ui/tests/access/AuthorizationService.test.ts b/ui/tests/access/AuthorizationService.test.ts new file mode 100644 index 00000000..d762e355 --- /dev/null +++ b/ui/tests/access/AuthorizationService.test.ts @@ -0,0 +1,93 @@ +import { setRoles } from '@/access/AuthorizationService'; +import Role from '@/access/Role'; +import User, { AnonymousUser } from '@/access/User'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); +const apiBaseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string; + +describe('AuthorizationService', () => { + + describe('setRoles', () => { + + let axiosMock: MockAdapter; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + AnonymousUser.roles = []; + testUser.roles = []; + }); + + it('should set the ANONYMOUS role', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(200, false); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(200, false); + + await setRoles(AnonymousUser); + expect(AnonymousUser.roles.includes(Role.ADMIN)).toBeFalsy(); + expect(AnonymousUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(AnonymousUser.roles.includes(Role.OWNER)).toBeFalsy(); + expect(AnonymousUser.roles.includes(Role.UH)).toBeFalsy(); + }); + + it('should set the UH role', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(200, false); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(200, false); + + await setRoles(testUser); + expect(testUser.roles.includes(Role.ADMIN)).toBeFalsy(); + expect(testUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(testUser.roles.includes(Role.OWNER)).toBeFalsy(); + expect(testUser.roles.includes(Role.UH)).toBeTruthy(); + }); + + it('should set the UH and ADMIN roles', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(200, false); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(200, true); + + await setRoles(testUser); + expect(testUser.roles.includes(Role.ADMIN)).toBeTruthy(); + expect(testUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(testUser.roles.includes(Role.OWNER)).toBeFalsy(); + expect(testUser.roles.includes(Role.UH)).toBeTruthy(); + }); + + it('should set the UH and OWNER roles', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(200, true); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(200, false); + + await setRoles(testUser); + expect(testUser.roles.includes(Role.ADMIN)).toBeFalsy(); + expect(testUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(testUser.roles.includes(Role.OWNER)).toBeTruthy(); + expect(testUser.roles.includes(Role.UH)).toBeTruthy(); + }); + + it('should set the UH, ADMIN, and OWNER roles', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(200, true); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(200, true); + + await setRoles(testUser); + expect(testUser.roles.includes(Role.ADMIN)).toBeTruthy(); + expect(testUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(testUser.roles.includes(Role.OWNER)).toBeTruthy(); + expect(testUser.roles.includes(Role.UH)).toBeTruthy(); + }); + + it('should catch Groupings API errors', async () => { + axiosMock.onGet(`${apiBaseUrl}/owners`).reply(500); + axiosMock.onGet(`${apiBaseUrl}/admins`).reply(500); + + await setRoles(testUser); + expect(testUser.roles.includes(Role.ADMIN)).toBeFalsy(); + expect(testUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(testUser.roles.includes(Role.OWNER)).toBeFalsy(); + expect(testUser.roles.includes(Role.UH)).toBeTruthy(); + }); + + }); + +}); diff --git a/ui/tests/access/Role.test.ts b/ui/tests/access/Role.test.ts new file mode 100644 index 00000000..8d37b46f --- /dev/null +++ b/ui/tests/access/Role.test.ts @@ -0,0 +1,11 @@ +import Role from '@/access/Role'; + +describe('Role', () => { + + it('should have the same key and value', () => { + for (const [key, value] of Object.entries(Role)) { + expect(key).toBe(value); + } + }); + +}); diff --git a/ui/tests/access/Saml11Validator.test.ts b/ui/tests/access/Saml11Validator.test.ts new file mode 100644 index 00000000..c81d0a33 --- /dev/null +++ b/ui/tests/access/Saml11Validator.test.ts @@ -0,0 +1,31 @@ +import { validateTicket } from '@/access/Saml11Validator'; +import User, { AnonymousUser } from '@/access/User'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); +const xmlSoapResponse = process.env.XML_SOAP_RESPONSE as string; + +describe('Saml11Validator', () => { + + describe('validateTicket', () => { + + let axiosMock: MockAdapter; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + it('should return the user on success', async () => { + axiosMock.onPost().reply(200, xmlSoapResponse); + expect(await validateTicket('ticket')).toEqual(testUser); + }); + + it('should return an AnonymousUser on error', async () => { + axiosMock.onPost().reply(500); + expect(await validateTicket('ticket')).toEqual(AnonymousUser); + }); + + }); + +}); diff --git a/ui/tests/access/User.test.ts b/ui/tests/access/User.test.ts new file mode 100644 index 00000000..00718823 --- /dev/null +++ b/ui/tests/access/User.test.ts @@ -0,0 +1,17 @@ +import Role from '@/access/Role'; +import { AnonymousUser } from '@/access/User'; + +describe('User', () => { + + describe('AnonymousUser', () => { + + it('should only have the Role ANONYMOUS', () => { + expect(AnonymousUser.roles.includes(Role.ANONYMOUS)).toBeTruthy(); + expect(AnonymousUser.roles.includes(Role.UH)).toBeFalsy(); + expect(AnonymousUser.roles.includes(Role.ADMIN)).toBeFalsy(); + expect(AnonymousUser.roles.includes(Role.OWNER)).toBeFalsy(); + }); + + }); + +}); diff --git a/ui/tests/app/api/cas/login/route.test.ts b/ui/tests/app/api/cas/login/route.test.ts new file mode 100644 index 00000000..35e29630 --- /dev/null +++ b/ui/tests/app/api/cas/login/route.test.ts @@ -0,0 +1,24 @@ +import { GET } from '@/app/api/cas/login/route'; +import { redirect } from 'next/navigation'; +import { handleLogin } from '@/access/AuthenticationService'; +import { NextRequest } from 'next/server'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +jest.mock('@/access/AuthenticationService'); + +describe('/api/cas/login', () => { + + const nextRequest = new NextRequest(new URL('/api/cas/login?ticket=ticket1', baseUrl)); + + it('should call handleLogin with ticket from search param', async () => { + await GET(nextRequest); + expect(handleLogin).toHaveBeenCalledWith('ticket1'); + }); + + it('should redirect to the baseUrl', async () => { + await GET(nextRequest); + expect(redirect).toHaveBeenCalledWith(baseUrl); + }); + +}); diff --git a/ui/tests/app/api/cas/logout/route.test.ts b/ui/tests/app/api/cas/logout/route.test.ts new file mode 100644 index 00000000..1374af4b --- /dev/null +++ b/ui/tests/app/api/cas/logout/route.test.ts @@ -0,0 +1,21 @@ +import { GET } from '@/app/api/cas/logout/route'; +import { redirect } from 'next/navigation'; +import { handleLogout } from '@/access/AuthenticationService'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +jest.mock('@/access/AuthenticationService'); + +describe('/api/cas/logout', () => { + + it('should call handleLogout', async () => { + await GET(); + expect(handleLogout).toHaveBeenCalled(); + }); + + it('should redirect to the baseUrl', async () => { + await GET(); + expect(redirect).toHaveBeenCalledWith(baseUrl); + }); + +}); diff --git a/ui/tests/components/layout/footer/Footer.test.tsx b/ui/tests/components/layout/footer/Footer.test.tsx new file mode 100644 index 00000000..22398483 --- /dev/null +++ b/ui/tests/components/layout/footer/Footer.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import Footer from '@/components/layout/footer/Footer'; + +describe ('Footer', () => { + + it('should render a footer with the UH System logo and appropriate links', () => { + render(); + + expect(screen.getByRole('contentinfo')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'UH System logo' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'equal opportunity/affirmative action institution' })) + .toHaveAttribute('href', 'https://www.hawaii.edu/offices/eeo/policies/?policy=antidisc'); + expect(screen.getByRole('link', { name: 'Usage Policy' })) + .toHaveAttribute('href', 'https://www.hawaii.edu/policy/docs/temp/ep2.210.pdf'); + }); + +}); diff --git a/ui/tests/components/layout/navbar/LoginButton.test.tsx b/ui/tests/components/layout/navbar/LoginButton.test.tsx new file mode 100644 index 00000000..dba73f42 --- /dev/null +++ b/ui/tests/components/layout/navbar/LoginButton.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import Login from '@/components/layout/navbar/LoginButton'; +import { redirect } from 'next/navigation'; +import User, { AnonymousUser } from '@/access/User'; +import Role from '@/access/Role'; + +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); + +describe('Login', () => { + + describe('User is not logged in', () => { + + it('should render a Login button', () => { + render(); + + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument; + }); + + it('should visit the CAS login url on click', () => { + render(); + + const casLoginUrl = `${casUrl}/login?service=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`; + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + expect(redirect).toHaveBeenCalledWith(casLoginUrl); + }); + + }); + + describe('User is logged in', () => { + + beforeAll(() => { + testUser.roles.push(Role.UH); + }); + + it('should render a Logout button with the uid of the logged-in user', () => { + render(); + + expect(screen.getByRole('button', { name: `Logout (${testUser.uid})` })).toBeInTheDocument; + }); + + it('should visit the CAS logout url on click', () => { + render(); + + const casLogoutUrl = `${casUrl}/logout?service=${encodeURIComponent(`${baseUrl}/api/cas/logout`)}`; + fireEvent.click(screen.getByRole('button', { name: `Logout (${testUser.uid})` })); + expect(redirect).toHaveBeenCalledWith(casLogoutUrl); + }); + + }); + +}); diff --git a/ui/tests/components/layout/navbar/MobileNavbar.test.tsx b/ui/tests/components/layout/navbar/MobileNavbar.test.tsx new file mode 100644 index 00000000..810a7c89 --- /dev/null +++ b/ui/tests/components/layout/navbar/MobileNavbar.test.tsx @@ -0,0 +1,86 @@ +import Role from '@/access/Role'; +import User, { AnonymousUser } from '@/access/User'; +import MobileNavbar from '@/components/layout/navbar/MobileNavbar'; +import { fireEvent, render, screen } from '@testing-library/react'; + +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); + +describe('MobileNavbar', () => { + + it('should render the MobileNavbar with the sheet closed', () => { + render(); + + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + }); + + it('should open the drawer on click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + + describe('User is logged-out', () => { + + it('should render the navbar with only the link to /about', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Memberships' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Groupings' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.queryByRole('link', { name: 'Feedback' })).not.toBeInTheDocument(); + }); + + }); + + describe('User is logged-in', () => { + + beforeEach(() => { + testUser.roles = [Role.ANONYMOUS]; + }) + + it('should render only /memberships, /about, /feedback for the average user', async () => { + testUser.roles.push(Role.UH); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.queryByRole('link', { name: 'Groupings' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + }); + + it('should render only /memberships, /groupings, /about, /feedback for an owner of a grouping', async () => { + testUser.roles.push(Role.OWNER, Role.UH); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.getByRole('link', { name: 'Groupings' })).toHaveAttribute('href', '/groupings'); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + }); + + it('should render all links for an Admin', async () => { + testUser.roles.push(Role.ADMIN, Role.UH); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open navigation menu' })); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Admin' })).toHaveAttribute('href', '/admin'); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.getByRole('link', { name: 'Groupings' })).toHaveAttribute('href', '/groupings'); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + }); + + }); + +}); diff --git a/ui/tests/components/layout/navbar/Navbar.test.tsx b/ui/tests/components/layout/navbar/Navbar.test.tsx new file mode 100644 index 00000000..174ead0a --- /dev/null +++ b/ui/tests/components/layout/navbar/Navbar.test.tsx @@ -0,0 +1,92 @@ +import User, { AnonymousUser } from '@/access/User'; +import * as AuthenticationService from '@/access/AuthenticationService'; +import { render, screen } from '@testing-library/react'; +import Navbar from '@/components/layout/navbar/Navbar'; +import Role from '@/access/Role'; + +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); + +jest.mock('@/access/AuthenticationService'); + +describe('Navbar', () => { + + describe('User is logged-out', () => { + + it('should render the navbar with only the link to /about', async () => { + jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(AnonymousUser); + render(await Navbar()); + + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.getAllByRole('img', { name: 'UH Groupings Logo' })[0]) + .toHaveAttribute('src', 'uhgroupings/uh-groupings-logo.svg'); + expect(screen.getAllByRole('link', { name: 'UH Groupings Logo' })[0]).toHaveAttribute('href', '/'); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Memberships' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Groupings' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.queryByRole('link', { name: 'Feedback' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument(); + }); + + }); + + describe('User is logged-in', () => { + + beforeEach(() => { + testUser.roles = [Role.ANONYMOUS]; + }) + + it('should render only /memberships, /about, /feedback for the average user', async () => { + testUser.roles.push(Role.UH); + jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(testUser); + render(await Navbar()); + + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.getAllByRole('img', { name: 'UH Groupings Logo' })[0]) + .toHaveAttribute('src', 'uhgroupings/uh-groupings-logo.svg'); + expect(screen.getAllByRole('link', { name: 'UH Groupings Logo' })[0]).toHaveAttribute('href', '/'); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.queryByRole('link', { name: 'Groupings' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + expect(screen.getByRole('button', { name: `Logout (${testUser.uid})` })).toBeInTheDocument(); + }); + + it('should render only /memberships, /groupings, /about, /feedback for an owner of a grouping', async () => { + testUser.roles.push(Role.OWNER, Role.UH); + jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(testUser); + render(await Navbar()); + + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.getAllByRole('img', { name: 'UH Groupings Logo' })[0]) + .toHaveAttribute('src', 'uhgroupings/uh-groupings-logo.svg'); + expect(screen.getAllByRole('link', { name: 'UH Groupings Logo' })[0]).toHaveAttribute('href', '/'); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.getByRole('link', { name: 'Groupings' })).toHaveAttribute('href', '/groupings'); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + expect(screen.getByRole('button', { name: `Logout (${testUser.uid})` })).toBeInTheDocument(); + }); + + it('should render all links for an Admin', async () => { + testUser.roles.push(Role.ADMIN, Role.UH); + jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(testUser); + render(await Navbar()); + + expect(screen.getByRole('navigation')).toBeInTheDocument(); + expect(screen.getAllByRole('img', { name: 'UH Groupings Logo' })[0]) + .toHaveAttribute('src', 'uhgroupings/uh-groupings-logo.svg'); + expect(screen.getAllByRole('link', { name: 'UH Groupings Logo' })[0]).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Admin' })).toHaveAttribute('href', '/admin'); + expect(screen.getByRole('link', { name: 'Memberships' })).toHaveAttribute('href', '/memberships'); + expect(screen.getByRole('link', { name: 'Groupings' })).toHaveAttribute('href', '/groupings'); + expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about'); + expect(screen.getByRole('link', { name: 'Feedback' })).toHaveAttribute('href', '/feedback'); + expect(screen.getByRole('button', { name: `Logout (${testUser.uid})` })).toBeInTheDocument(); + }); + + }); + +}); diff --git a/ui/tests/middleware.test.tsx b/ui/tests/middleware.test.tsx new file mode 100644 index 00000000..6c9cf2e5 --- /dev/null +++ b/ui/tests/middleware.test.tsx @@ -0,0 +1,107 @@ +import { config, middleware } from '@/middleware'; +import { createMockSession } from './setupJest'; +import { NextRequest, NextResponse } from 'next/server'; +import IronSession from 'iron-session'; +import User, { AnonymousUser } from '@/access/User'; +import Role from '@/access/Role'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; +const testUser: User = JSON.parse(process.env.TEST_USER_A as string); + +describe('middleware', () => { + + it('should define the config with a list of matching paths', () => { + expect(config).toBeDefined(); + expect(config.matcher).toBeDefined(); + }); + + describe('User is logged-out', () => { + + it('should redirect the user', async () => { + const redirectSpy = jest.spyOn(NextResponse, 'redirect'); + + for (const matcher of config.matcher) { + const url = baseUrl + matcher; + const req = new NextRequest(new Request(url)); + + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(undefined)); + await middleware(req); + expect(redirectSpy).toHaveBeenCalledWith(new URL(baseUrl)); + + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(AnonymousUser)); + await middleware(req); + expect(redirectSpy).toHaveBeenCalledWith(new URL(baseUrl)); + + redirectSpy.mockClear(); + } + }); + + }); + + describe('User is logged-in', () => { + + afterEach(() => { + testUser.roles = []; + }) + + it('should redirect the average user at /admin and /groupings', async () => { + testUser.roles.push(Role.UH); + const redirectSpy = jest.spyOn(NextResponse, 'redirect'); + + for (const matcher of config.matcher) { + const url = baseUrl + matcher; + const req = new NextRequest(new Request(url)); + + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(testUser)); + await middleware(req); + + if (matcher === '/admin' || matcher === '/groupings') { + expect(redirectSpy).toHaveBeenCalledWith(new URL(baseUrl)); + } else { + expect(redirectSpy).not.toHaveBeenCalled(); + } + + redirectSpy.mockClear(); + } + }); + + it('should redirect an owner of a grouping at /admin', async () => { + testUser.roles.push(Role.OWNER, Role.UH); + const redirectSpy = jest.spyOn(NextResponse, 'redirect'); + + for (const matcher of config.matcher) { + const url = baseUrl + matcher; + const req = new NextRequest(new Request(url)); + + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(testUser)); + await middleware(req); + + if (matcher === '/admin') { + expect(redirectSpy).toHaveBeenCalledWith(new URL(baseUrl)); + } else { + expect(redirectSpy).not.toHaveBeenCalled(); + } + + redirectSpy.mockClear(); + } + }); + + it('should not redirect an admin', async () => { + testUser.roles.push(Role.ADMIN); + const redirectSpy = jest.spyOn(NextResponse, 'redirect'); + + for (const matcher of config.matcher) { + const url = baseUrl + matcher; + const req = new NextRequest(new Request(url)); + + jest.spyOn(IronSession, 'getIronSession').mockResolvedValue(createMockSession(testUser)); + await middleware(req); + + expect(redirectSpy).not.toHaveBeenCalled(); + redirectSpy.mockClear(); + } + }); + + }); + +}); diff --git a/ui/tests/setupJest.tsx b/ui/tests/setupJest.tsx new file mode 100644 index 00000000..d9244e8c --- /dev/null +++ b/ui/tests/setupJest.tsx @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom'; +import { loadEnvConfig } from '@next/env'; +import { enableFetchMocks } from 'jest-fetch-mock'; +import User from '@/access/User'; + +enableFetchMocks(); +loadEnvConfig(process.cwd()); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +export const createMockSession = (user: User | undefined) => ({ + user, + destroy: jest.fn(), + save: jest.fn(), + updateConfig: jest.fn() +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 00000000..9bb90ae6 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/ui/uh-groupings-ui-3-0-overrides.skeleton.properties b/ui/uh-groupings-ui-3-0-overrides.skeleton.properties new file mode 100644 index 00000000..2ed25b98 --- /dev/null +++ b/ui/uh-groupings-ui-3-0-overrides.skeleton.properties @@ -0,0 +1,7 @@ +# Create a directory in your home directory called ".-conf". +# Copy this file into that directory and name it uh-groupings-ui-3-0-overrides.properties. +# Note that properties filename will be temporary until UH Groupings 3.0 is complete. +# Fill in all blank values. + +# Iron Session. Generate a password that is at least 32 characters long (https://1password.com/password-generator/). +IRON_SESSION_SECRET=