diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..d488d010 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,36 @@ +--- +name: Playwright Tests +on: + deployment_status: + paths: + - playwright/** + - .github/workflows/playwright.yml +defaults: + run: + working-directory: ./playwright +env: + PW_XFD_2FA_ISSUER: ${{ secrets._PW_XFD_2FA_ISSUER }} + PW_XFD_2FA_SECRET: ${{ secrets.PW_XFD_2FA_SECRET }} + PW_XFD_PASSWORD: ${{ secrets.PW_XFD_PASSWORD }} + PW_XFD_URL: ${{ vars.PW_XFD_URL }} + PW_XFD_USER_ROLE: ${{ vars.PW_XFD_USER_ROLE }} + PW_XFD_USERNAME: ${{ secrets.PW_XFD_USERNAME }} + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.41.2-jammy + if: github.event.deployment_status.state == 'success' + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Run your tests + run: npx playwright test + env: + HOME: /root diff --git a/backend/package-lock.json b/backend/package-lock.json index c85c76a1..0375c807 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9528,6 +9528,7 @@ "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" }, "dev": true, @@ -9535,9 +9536,30 @@ "node": ">=0.10" }, "hasInstallScript": true, - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "version": "0.10.62" + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "version": "0.10.64" + }, + "node_modules/es5-ext/node_modules/esniff": { + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "dev": true, + "engines": { + "node": ">=0.10" + }, + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/es5-ext/node_modules/type": { + "dev": true, + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "version": "2.7.2" }, "node_modules/es6-error": { "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", @@ -10086,10 +10108,10 @@ "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -10119,9 +10141,9 @@ "engines": { "node": ">= 0.10.0" }, - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "version": "4.18.2" + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "version": "4.19.2" }, "node_modules/express-rate-limit": { "engines": { @@ -10137,36 +10159,13 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.2.0.tgz", "version": "7.2.0" }, - "node_modules/express/node_modules/body-parser": { - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - }, - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "version": "1.20.1" - }, "node_modules/express/node_modules/cookie": { "engines": { "node": ">= 0.6" }, - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "version": "0.5.0" + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "version": "0.6.0" }, "node_modules/express/node_modules/debug": { "dependencies": { @@ -10181,20 +10180,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "version": "2.0.0" }, - "node_modules/express/node_modules/raw-body": { - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - }, - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "version": "2.5.1" - }, "node_modules/ext": { "dependencies": { "type": "^2.7.2" @@ -10588,14 +10573,14 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "peerDependenciesMeta": { "debug": { "optional": true } }, - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "version": "1.15.4" + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "version": "1.15.6" }, "node_modules/for-each": { "dependencies": { @@ -14254,9 +14239,9 @@ "funding": { "url": "https://github.com/sponsors/panva" }, - "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", - "version": "4.14.4" + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "version": "4.15.5" }, "node_modules/js-tokens": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 9bdf5004..0b8af9dc 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -112,6 +112,7 @@ app.get('/', handlerToExpress(healthcheck)); app.post('/auth/login', handlerToExpress(auth.login)); app.post('/auth/callback', handlerToExpress(auth.callback)); app.post('/users/register', handlerToExpress(users.register)); +app.post('/readysetcyber/register', handlerToExpress(users.RSCRegister)); const checkUserLoggedIn = async (req, res, next) => { req.requestContext = { @@ -276,7 +277,6 @@ app.use( const authenticatedNoTermsRoute = express.Router(); authenticatedNoTermsRoute.use(checkUserLoggedIn); authenticatedNoTermsRoute.get('/users/me', handlerToExpress(users.me)); -// authenticatedNoTermsRoute.post('/users/register', handlerToExpress(users.register)); authenticatedNoTermsRoute.post( '/users/me/acceptTerms', handlerToExpress(users.acceptTerms) diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts index ccc4a36c..983eb759 100644 --- a/backend/src/api/users.ts +++ b/backend/src/api/users.ts @@ -254,6 +254,30 @@ If you encounter any difficulties, please feel free to reply to this email (or s ); }; +const sendRSCInviteEmail = async (email: string) => { + const staging = process.env.NODE_ENV !== 'production'; + + await sendEmail( + email, + 'ReadySetCyber Dashboard Invitation', + `Hi there, + +You've been invited to join ReadySetCyber Dashboard. To accept the invitation and start using your Dashboard, sign on at ${process.env.FRONTEND_DOMAIN}/readysetcyber/create-account. + +Crossfeed access instructions: + +1. Visit ${process.env.FRONTEND_DOMAIN}/readysetcyber/create-account. +2. Select "Create Account." +3. Enter your email address and a new password for Crossfeed. +4. A confirmation code will be sent to your email. Enter this code when you receive it. +5. You will be prompted to enable MFA. Scan the QR code with an authenticator app on your phone, such as Microsoft Authenticator. Enter the MFA code you see after scanning. +6. After configuring your account, you will be redirected to Crossfeed. + +For more information on using Crossfeed, view the Crossfeed user guide at https://docs.crossfeed.cyber.dhs.gov/user-guide/quickstart/. + +If you encounter any difficulties, please feel free to reply to this email (or send an email to ${process.env.CROSSFEED_SUPPORT_EMAIL_REPLYTO}).` + ); +}; /** * @swagger * @@ -892,3 +916,52 @@ export const updateV2 = wrapHandler(async (event) => { } return NotFound; }); + +/** + * @swagger + * + * /readysetcyber/register: + * post: + * description: New ReadySetCyber user registration. + * tags: + * - RSCUsers + */ +export const RSCRegister = wrapHandler(async (event) => { + const body = await validateBody(NewUser, event.body); + const newRSCUser = { + firstName: body.firstName, + lastName: body.lastName, + email: body.email.toLowerCase(), + userType: UserType.READY_SET_CYBER + }; + + await connectToDatabase(); + + // Check if user already exists + let user = await User.findOne({ + email: newRSCUser.email + }); + if (user) { + console.log('User already exists.'); + return { + statusCode: 422, + body: 'User email already exists. Registration failed.' + }; + // Create if user does not exist + } else { + user = await User.create(newRSCUser); + await User.save(user); + // Send email notification + if (process.env.IS_LOCAL!) { + console.log('Cannot send invite email while running on local.'); + } else { + await sendRSCInviteEmail(user.email); + } + } + + const savedUser = await User.findOne(user.id); + return { + statusCode: 200, + body: JSON.stringify(savedUser) + }; +}); diff --git a/backend/test/assessments.test.ts b/backend/test/assessments.test.ts index e69de29b..266a61dc 100644 --- a/backend/test/assessments.test.ts +++ b/backend/test/assessments.test.ts @@ -0,0 +1,4 @@ +import { expect, test } from '@jest/globals'; +test('dummy test', () => { + expect(true).toBe(true); +}); diff --git a/backend/worker/requirements.txt b/backend/worker/requirements.txt index 350a28f2..c15afd3f 100644 --- a/backend/worker/requirements.txt +++ b/backend/worker/requirements.txt @@ -14,7 +14,7 @@ numpy==1.24.3 pandas==2.1.4 phonenumbers==8.13.8 pip-tools==7.1.0 -pipreqs==0.4.11 +pipreqs==0.4.12 psycopg2-binary==2.9.5 pyproject_hooks==1.0.0 pytest==7.3.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a46b56c1..9442f0c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21614,7 +21614,7 @@ "node_modules/body-parser": { "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -21622,7 +21622,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -21630,9 +21630,9 @@ "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" }, - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "version": "1.20.1" + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "version": "1.20.2" }, "node_modules/body-parser/node_modules/bytes": { "engines": { @@ -22744,9 +22744,9 @@ "engines": { "node": ">= 0.6" }, - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "version": "0.5.0" + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "version": "0.6.0" }, "node_modules/cookie-signature": { "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", @@ -24538,6 +24538,7 @@ "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" }, "dev": true, @@ -24545,9 +24546,24 @@ "node": ">=0.10" }, "hasInstallScript": true, - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "version": "0.10.62" + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "version": "0.10.64" + }, + "node_modules/es5-ext/node_modules/esniff": { + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "dev": true, + "engines": { + "node": ">=0.10" + }, + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/es6-iterator": { "dependencies": { @@ -25535,10 +25551,10 @@ "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -25568,9 +25584,9 @@ "engines": { "node": ">= 0.10.0" }, - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "version": "4.18.2" + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "version": "4.19.2" }, "node_modules/express-rate-limit": { "engines": { @@ -26314,14 +26330,14 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "peerDependenciesMeta": { "debug": { "optional": true } }, - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "version": "1.15.4" + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "version": "1.15.6" }, "node_modules/for-each": { "dependencies": { @@ -36726,9 +36742,9 @@ "engines": { "node": ">= 0.8" }, - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "version": "2.5.1" + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "version": "2.5.2" }, "node_modules/raw-body/node_modules/bytes": { "engines": { @@ -43387,12 +43403,12 @@ "type": "opencollective", "url": "https://opencollective.com/webpack" }, - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" }, - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "version": "5.3.3" + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "version": "5.3.4" }, "node_modules/webpack-dev-middleware/node_modules/colorette": { "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 818a0f3d..939394c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { } from '@jonkoops/matomo-tracker-react'; import { Domain, + AdminTools, AuthLogin, AuthLoginCreate, AuthCreateAccount, @@ -38,6 +39,10 @@ import { import { Layout, RouteGuard } from 'components'; import './styles.scss'; import { Authenticator } from '@aws-amplify/ui-react'; +import { RSCDashboard } from 'components/ReadySetCyber/RSCDashboard'; +import { RSCDetail } from 'components/ReadySetCyber/RSCDetail'; +import { RSCLogin } from 'components/ReadySetCyber/RSCLogin'; +import { RSCAuthLoginCreate } from 'components/ReadySetCyber/RSCAuthLoginCreate'; API.configure({ endpoints: [ @@ -167,21 +172,17 @@ const App: React.FC = () => ( permissions={['standard', 'globalView']} /> ( component={RegionUsers} permissions={['regionalAdmin']} /> + } + unauth={RSCLogin} + component={RSCDashboard} + /> + } + unauth={RSCAuthLoginCreate} + component={RSCDashboard} + /> + } + permissions={['readySetCyber']} + unauth={RSCLogin} + /> + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index ace0f480..202b1b61 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -186,12 +186,12 @@ const HeaderNoCtx: React.FC = (props) => { if (user && user.isRegistered) { if (user.userType === 'standard') { userLevel = STANDARD_USER; + } else if (user.userType === 'globalAdmin') { + userLevel = GLOBAL_ADMIN; } else if ( - user.userType === 'globalAdmin' || + user.userType === 'regionalAdmin' || user.userType === 'globalView' ) { - userLevel = GLOBAL_ADMIN; - } else if (user.userType === 'regionalAdmin') { userLevel = REGIONAL_ADMIN; } } @@ -230,13 +230,6 @@ const HeaderNoCtx: React.FC = (props) => { users: STANDARD_USER, exact: false, onClick: toggleDrawer(false) - }, - { - title: 'Scans', - path: '/scans', - users: GLOBAL_ADMIN, - exact: true, - onClick: toggleDrawer(false) } ].filter(({ users }) => users <= userLevel); @@ -249,6 +242,12 @@ const HeaderNoCtx: React.FC = (props) => { path: '#', exact: false, nested: [ + { + title: 'Admin Tools', + path: '/admin-tools/scans', + users: GLOBAL_ADMIN, + exact: true + }, { title: 'User Registration', path: '/region-admin-dashboard', @@ -258,7 +257,7 @@ const HeaderNoCtx: React.FC = (props) => { { title: 'Manage Organizations', path: '/organizations', - users: GLOBAL_ADMIN, + users: REGIONAL_ADMIN, exact: true }, { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 29a887fa..402c3ff6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,6 +7,8 @@ import { useUserActivityTimeout } from 'hooks/useUserActivityTimeout'; import { useAuthContext } from 'context/AuthContext'; import UserInactiveModal from './UserInactivityModal/UserInactivityModal'; import { CrossfeedFooter } from './Footer'; +import { RSCFooter } from './ReadySetCyber/RSCFooter'; +import { RSCHeader } from './ReadySetCyber/RSCHeader'; interface LayoutProps { children: React.ReactNode; @@ -47,14 +49,23 @@ export const Layout: React.FC = ({ children }) => { countdown={60} // 60 second timer for user inactivity timeout /> -
- - {pathname === '/inventory' ? ( - children + {!pathname.includes('/readysetcyber') ? ( + <> +
+ {pathname === '/inventory' ? ( + children + ) : ( +
{children}
+ )} + + ) : ( -
{children}
+ <> + +
{children}
+ + )} - ); diff --git a/frontend/src/components/ReadySetCyber/RSCAuthLoginCreate.tsx b/frontend/src/components/ReadySetCyber/RSCAuthLoginCreate.tsx new file mode 100644 index 00000000..dce70908 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCAuthLoginCreate.tsx @@ -0,0 +1,240 @@ +import React, { useEffect, useState } from 'react'; +import { AuthForm } from 'components'; +import { Button } from '@trussworks/react-uswds'; +import { + Box, + Dialog, + DialogContent, + DialogTitle, + Link, + Typography +} from '@mui/material'; +import { useAuthContext } from 'context'; +import { + Authenticator, + ThemeProvider, + useAuthenticator +} from '@aws-amplify/ui-react'; +import { I18n } from 'aws-amplify'; + +import { RSCRegisterForm } from 'components/ReadySetCyber/RSCRegisterForm'; + +const TOTP_ISSUER = process.env.REACT_APP_TOTP_ISSUER; + +// Strings come from https://github.com/aws-amplify/amplify-ui/blob/main/packages/ui/src/i18n/dictionaries/authenticator/en.ts +I18n.putVocabulariesForLanguage('en-US', { + 'Setup TOTP': 'Set up 2FA', + 'Confirm TOTP Code': 'Enter 2FA Code' +}); + +const amplifyTheme = { + name: 'my-theme' +}; + +interface Errors extends Partial { + global?: string; +} + +export const RSCAuthLoginCreate: React.FC<{ showSignUp?: boolean }> = ({ + showSignUp = true +}) => { + const { apiPost, refreshUser } = useAuthContext(); + const [errors, setErrors] = useState({}); + const [open, setOpen] = useState(false); + const [registerSuccess, setRegisterSuccess] = useState(false); + // Once a user signs in, call refreshUser() so that the callback is called and the user gets signed in. + const { authStatus } = useAuthenticator((context) => [context.isPending]); + useEffect(() => { + refreshUser(); + }, [refreshUser, authStatus]); + + const formFields = { + signIn: { + username: { + label: 'Email', + placeholder: 'Enter your email address', + required: true, + autoFocus: true + }, + password: { + label: 'Password', + placeholder: 'Enter your password', + required: true + } + }, + confirmSignIn: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code from your authenticator app', + autoFocus: true + } + }, + resetPassword: { + username: { + label: 'Email', + placeholder: 'Enter your email address', + required: true, + autoFocus: true + } + }, + confirmResetPassword: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code sent to your email address', + autoFocus: true + } + }, + confirmSignUp: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code sent to your email address', + autoFocus: true + } + }, + setupTOTP: { + QR: { + // Set the issuer and name so that the authenticator app shows them. + // TODO: Set the issuer to the email, once this is resolved: https://github.com/aws-amplify/amplify-ui/issues/3387. + totpIssuer: TOTP_ISSUER + // totpUsername: email, + }, + confirmation_code: { + label: + 'Set up 2FA by scanning the QR code with an authenticator app on your phone.', + autoFocus: true + } + } + }; + + const onSubmit: React.FormEventHandler = async (e) => { + e.preventDefault(); + try { + const { redirectUrl, state, nonce } = await apiPost('/auth/login', { + body: {} + }); + localStorage.setItem('state', state); + localStorage.setItem('nonce', nonce); + window.location.href = redirectUrl; + } catch (e) { + console.error(e); + setErrors({ + global: 'Something went wrong logging in.' + }); + } + }; + + const onClose = () => { + setOpen(false); + }; + + const RegistrationSuccessDialog = ( + setRegisterSuccess(false)} + maxWidth="xs" + > + REQUEST SENT + + Thank you for requesting a ReadySetCyber Dashboard account, you will + receive notification once this request is approved. + + + ); + + if (process.env.REACT_APP_USE_COGNITO) { + return ( + +

Welcome to ReadySetCyber Dashboard

+ + + + {/* alert('hello')}> + Register + */} + + {open && ( + + )} + {RegistrationSuccessDialog} + + + New to ReadySetCyber Dashboard?  + + setOpen(true)} + > + Register Now + + + +
**Warning**
+
+ {' '} + This system contains U.S. Government Data. Unauthorized use of this + system is prohibited. Use of this computer system, authorized or + unauthorized, constitutes consent to monitoring of this system. +
+
+ {' '} + This computer system, including all related equipment, networks, and + network devices (specifically including Internet access) are + provided only for authorized U.S. Government use. U.S. Government + computer systems may be monitored for all lawful purposes, including + to ensure that their use is authorized, for management of the + system, to facilitate protection against unauthorized access, and to + verify security procedures, survivability, and operational security. + Monitoring includes active attacks by authorized U.S. Government + entities to test or verify the security of this system. During + monitoring, information may be examined, recorded, copied and used + for authorized purposes. All information, including personal + information, placed or sent over this system may be monitored. +
+
+ {' '} + Unauthorized use may subject you to criminal prosecution. Evidence + of unauthorized use collected during monitoring may be used for + administrative, criminal, or other adverse action. Use of this + system constitutes consent to monitoring for these purposes. +
+
+
+ ); + } + + return ( + +

Welcome to ReadySetCyber Dashboard

+ {errors.global &&

{errors.global}

} + + +
New to ReadySetCyber Dashboard? Register with Login.gov
+
+ {open && ( + + )} + {RegistrationSuccessDialog} + +
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCDashboard.tsx b/frontend/src/components/ReadySetCyber/RSCDashboard.tsx new file mode 100644 index 00000000..567e2bd6 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCDashboard.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import Divider from '@mui/material/Divider'; +import { RSCSideNav } from './RSCSideNav'; +import { RSCResult } from './RSCResult'; + +import { dummyResults } from './dummyData'; + +const results = dummyResults; + +export const RSCDashboard: React.FC = () => { + return ( + + + + + + + + +

Assessment Results

+ +

Thank you for completing the ReadySetCyber questionnaire!

+

+ Below, you’ll find a summary of all completed ReadySetCyber + questionnaires. Selecting a result will allow you to review + areas where you can improve your organization’s cybersecurity + posture, along with recommended resources to help address those + areas. To take further action, contact your regional CISA + Cybersecurity Advisor (CSA) for personalized support. You can + also explore Crossfeed, CISA’s Attack Surface Management + platform, for free vulnerability scanning services to kickstart + or enhance your cybersecurity measures. +

+ {results.map((result) => ( + + + + + ))} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCDetail.tsx b/frontend/src/components/ReadySetCyber/RSCDetail.tsx new file mode 100644 index 00000000..ed6c1795 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCDetail.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import Divider from '@mui/material/Divider'; +import Button from '@mui/material/Button'; +import { RSCSideNav } from './RSCSideNav'; +import { RSCResult } from './RSCResult'; +import { RSCQuestion } from './RSCQuestion'; +import { dummyResults } from './dummyData'; +import { Typography } from '@mui/material'; + +export const RSCDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const result = dummyResults.find((result) => result.id === parseInt(id)) || { + id: 0, + type: '', + date: '' + }; + const { questions } = dummyResults.find( + (result) => result.id === parseInt(id) + ) || { questions: [] }; + return ( + + + + + + + + + + + Summary and Resources + + + + +

Thank you for completing the ReadySetCyber questionnaire!

+

+ Below, you’ll find a full summary of your completed + ReadySetCyber questionnaire. Please note the areas where you can + improve your organization’s cybersecurity posture, along with + the recommended resources to help you address these areas. To + take further action, contact your regional CISA Cybersecurity + Advisor (CSA) for personalized support. You can also explore + Crossfeed, CISA’s Attack Surface Management platform, for free + vulnerability scanning services to kickstart or enhance your + cybersecurity measures. +

+ + + +
+ {questions.map((question) => ( + <> + +
+ + ))} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCFooter.tsx b/frontend/src/components/ReadySetCyber/RSCFooter.tsx new file mode 100644 index 00000000..27602f35 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCFooter.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { BottomNavigation } from '@mui/material'; + +export const RSCFooter: React.FC = () => { + return ( + +

Ready Set Cyber Footer

+
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCHeader.tsx b/frontend/src/components/ReadySetCyber/RSCHeader.tsx new file mode 100644 index 00000000..f3896b28 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCHeader.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { AppBar } from '@mui/material'; +import { Toolbar } from '@mui/material'; +import { Typography } from '@mui/material'; +import { useHistory } from 'react-router-dom'; + +export const RSCHeader: React.FC = () => { + const history = useHistory(); + const handleClick = () => { + history.push('/readysetcyber'); + }; + return ( + + + + Ready Set Cyber + + + + ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCLogin.tsx b/frontend/src/components/ReadySetCyber/RSCLogin.tsx new file mode 100644 index 00000000..8179bb08 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCLogin.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from 'react'; +import { AuthForm } from 'components'; +import { useAuthContext } from 'context'; +import { Button } from '@trussworks/react-uswds'; +import { + Box, + Dialog, + DialogContent, + DialogTitle, + Grid, + Link, + Typography +} from '@mui/material'; +import { RSCRegisterForm } from 'components/ReadySetCyber/RSCRegisterForm'; +import { CrossfeedWarning } from 'components/WarningBanner'; +import { Authenticator, useAuthenticator } from '@aws-amplify/ui-react'; +import { I18n } from 'aws-amplify'; + +const TOTP_ISSUER = process.env.REACT_APP_TOTP_ISSUER; +I18n.putVocabulariesForLanguage('en-US', { + 'Setup TOTP': 'Set up 2FA', + 'Confirm TOTP Code': 'Enter 2FA Code' +}); + +interface Errors extends Partial { + global?: string; +} +export const RSCLogin: React.FC<{ showSignUp?: boolean }> = ({ + showSignUp = false +}) => { + const { apiPost, refreshUser } = useAuthContext(); + const [errors, setErrors] = useState({}); + const [open, setOpen] = useState(false); + const [registerSuccess, setRegisterSuccess] = useState(false); + // Once a user signs in, call refreshUser() so that the callback is called and the user gets signed in. + const { authStatus } = useAuthenticator((context) => [context.isPending]); + useEffect(() => { + refreshUser(); + }, [refreshUser, authStatus]); + + const formFields = { + signIn: { + username: { + label: 'Email', + placeholder: 'Enter your email address', + required: true, + autoFocus: true + }, + password: { + label: 'Password', + placeholder: 'Enter your password', + required: true + } + }, + confirmSignIn: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code from your authenticator app', + autoFocus: true + } + }, + resetPassword: { + username: { + label: 'Email', + placeholder: 'Enter your email address', + required: true, + autoFocus: true + } + }, + confirmResetPassword: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code sent to your email address', + autoFocus: true + } + }, + confirmSignUp: { + confirmation_code: { + label: 'Confirmation Code', + placeholder: 'Enter code sent to your email address', + autoFocus: true + } + }, + setupTOTP: { + QR: { + // Set the issuer and name so that the authenticator app shows them. + // TODO: Set the issuer to the email, once this is resolved: https://github.com/aws-amplify/amplify-ui/issues/3387. + totpIssuer: TOTP_ISSUER + }, + confirmation_code: { + label: + 'Set up 2FA by scanning the QR code with an authenticator app on your phone.', + autoFocus: true + } + } + }; + + const onSubmit: React.FormEventHandler = async (e) => { + e.preventDefault(); + try { + const { redirectUrl, state, nonce } = await apiPost('/auth/login', { + body: {} + }); + localStorage.setItem('state', state); + localStorage.setItem('nonce', nonce); + window.location.href = redirectUrl; + } catch (e) { + console.error(e); + setErrors({ + global: 'Something went wrong logging in.' + }); + } + }; + const onClose = () => { + setOpen(false); + }; + // const platformNotification = ( + // + // + // + // {' '} + // PLATFORM NOTIFICATION: Temporary Downtime During Crossfeed Migration + // + // + // The Crossfeed environment is moving. The migration will require a a + // temporary downtime of approximately one week. The downtime will begin on + // Wednesday, October 25, through the day Wednesday, November 01. For + // additional information, please click{' '} + // + // here + // + // . + // + // + // ); + const RegistrationSuccessDialog = ( + setRegisterSuccess(false)} + maxWidth="xs" + > + REQUEST SENT + + Thank you for requesting a ReadySetCyber Dashboard account, you will + receive notification once this request is approved. + + + ); + if (process.env.REACT_APP_USE_COGNITO) { + return ( + + {/* platformNotification should go here */} + + + Welcome to ReadySetCyber Dashboard + + + + + + + {open && ( + + )} + {RegistrationSuccessDialog} + + + New to ReadySetCyber Dashboard?  + + setOpen(true)} + > + Register Now + + + + + + + + ); + } + return ( + +

Welcome to ReadySetCyber Dashboard

+ {errors.global &&

{errors.global}

} + + +
New to ReadySetCyber Dashboard? Register with Login.gov
+
+ {open && ( + + )} + {RegistrationSuccessDialog} + +
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCNavItem.tsx b/frontend/src/components/ReadySetCyber/RSCNavItem.tsx new file mode 100644 index 00000000..1addd073 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCNavItem.tsx @@ -0,0 +1,15 @@ +import { Divider, ListItem } from '@mui/material'; +import React from 'react'; + +interface Props { + name: string; +} +export const RSCNavItem: React.FC = (props) => { + const { name } = props; + return ( + <> + {name} + + + ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCQuestion.tsx b/frontend/src/components/ReadySetCyber/RSCQuestion.tsx new file mode 100644 index 00000000..153a677c --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCQuestion.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; + +interface Props { + question: { + id: number; + title: string; + answers: { + id: number; + name: string; + selected: boolean; + }[]; + category: string; + }; +} + +export const RSCQuestion: React.FC = (props) => { + const question = props.question; + const answers = props.question.answers; + return ( +
+ + + Question {question.id} + + + {question.title} + + + {answers.map((answer) => ( + + ))} + + +

Recommended Resources

+

Resource Type

+
Resource Title
+

Resource Description

+
+
+
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCRegisterForm.tsx b/frontend/src/components/ReadySetCyber/RSCRegisterForm.tsx new file mode 100644 index 00000000..1626a794 --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCRegisterForm.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useState } from 'react'; +import * as RSCregisterFormStyles from './RSCregisterFormStyle'; +import { + Button, + CircularProgress, + DialogActions, + DialogContent, + DialogTitle, + TextField +} from '@mui/material'; +import { Save } from '@mui/icons-material'; +import { User } from 'types'; + +const StyledDialog = RSCregisterFormStyles.StyledDialog; + +export interface RegisterFormValues { + firstName: string; + lastName: string; + email: string; +} + +export interface ApiResponse { + result: User; + count: number; + url?: string; +} + +export const RSCRegisterForm: React.FC<{ + open: boolean; + onClose: () => void; + setRegisterSuccess: Function; +}> = ({ open, onClose, setRegisterSuccess }) => { + // Set default Values + const defaultValues = () => ({ + firstName: '', + lastName: '', + email: '' + }); + + const registerRSCUserPost = async (body: Object) => { + try { + const requestOptions: RequestInit = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; + const response = await fetch( + process.env.REACT_APP_API_URL + '/readysetcyber/register', + requestOptions + ); + const data = await response.json(); + // Handle the response data here + console.log(data); + return data; + } catch (error) { + // Handle any errors here + console.error(error); + } + }; + + const [values, setValues] = useState(defaultValues); + const [errorRequestMessage, setErrorRequestMessage] = useState(''); + const [errorEmailMessage, setEmailErrorMessage] = useState( + 'Email entry error. Please try again.' + ); + const [isLoading, setIsLoading] = useState(false); + + const onTextChange: React.ChangeEventHandler< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > = (e) => onChange(e.target.name, e.target.value); + + const onChange = (name: string, value: any) => { + setValues((values) => ({ + ...values, + [name]: value + })); + }; + + const isDisabled = () => { + if (Object.values(values).every((value) => value) && !errorEmailMessage) { + return false; + } else { + return true; + } + }; + + const onSave = async () => { + setIsLoading(true); + console.log('values: ', values); + console.log('This is where we will send the values to post.'); + const body = { + firstName: values.firstName, + lastName: values.lastName, + email: values.email + }; + const registeredUser = await registerRSCUserPost(body); + if (registeredUser !== undefined) { + console.log('User Registered Successfully'); + setIsLoading(false); + onClose(); + setRegisterSuccess(true); + } else { + console.log('User Register Failed'); + setErrorRequestMessage( + 'Something went wrong registering. Please try again.' + ); + setIsLoading(false); + } + }; + + const validateEmail = (email: string) => { + // email format + const regexEmail = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + let error = false; + if (email && regexEmail.test(email)) { + setEmailErrorMessage(''); + error = false; + } else { + setEmailErrorMessage('Email is not valid.'); + error = true; + } + return error; + }; + + useEffect(() => { + if (values && values.email) { + validateEmail(values.email); + } + }, [values]); + + return ( + onClose} + // aria-labelledby="form-dialog-title" + maxWidth="xs" + fullWidth + > + + Register with RSC Dashboard + + + {errorRequestMessage && ( +

{errorRequestMessage}

+ )} + Email + + First Name + + Last Name + +
+ + + + +
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCResult.tsx b/frontend/src/components/ReadySetCyber/RSCResult.tsx new file mode 100644 index 00000000..b78f446c --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCResult.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import Card from '@mui/material/Card'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { Typography } from '@mui/material'; +import { useHistory } from 'react-router-dom'; + +interface Props { + id: number; + type: string; + date: string; + categories: { + id: number; + name: string; + }[]; + questions: { + id: number; + title: string; + answers: { + id: number; + name: string; + selected: boolean; + }[]; + }[]; +} + +export const RSCResult: React.FC = (props) => { + const { id, type, date } = props; + const history = useHistory(); + const handleClick = () => { + history.push(`/readysetcyber/result/${id}`); + }; + return ( + + + + + {type} + + {date} + + + + ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCSideNav.tsx b/frontend/src/components/ReadySetCyber/RSCSideNav.tsx new file mode 100644 index 00000000..e9a38c6f --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCSideNav.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import Divider from '@mui/material/Divider'; +import { RSCNavItem } from './RSCNavItem'; +import { dummyResults } from './dummyData'; + +export const RSCSideNav: React.FC = () => { + const { id } = useParams<{ id: string }>(); + + const categories = + dummyResults.find((result) => result.id === parseInt(id))?.categories || []; + + return ( +
+ + + Welcome User + + {categories.map((category) => ( + + ))} + Take Questionnaire Again + + Logout + + +
+ ); +}; diff --git a/frontend/src/components/ReadySetCyber/RSCregisterFormStyle.ts b/frontend/src/components/ReadySetCyber/RSCregisterFormStyle.ts new file mode 100644 index 00000000..f8ee2f1f --- /dev/null +++ b/frontend/src/components/ReadySetCyber/RSCregisterFormStyle.ts @@ -0,0 +1,48 @@ +import { styled } from '@mui/material/styles'; +import { Dialog } from '@mui/material'; + +const PREFIX = 'RegisterForm'; + +export const classes = { + chip: `${PREFIX}-chip`, + headerRow: `${PREFIX}-headerRow` +}; + +export const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`& .${classes.chip}`]: { + backgroundColor: '#C4C4C4', + color: 'white', + marginRight: '10px' + }, + + [`& .${classes.headerRow}`]: { + padding: '0.5rem 0', + width: '100%', + display: 'flex', + alignItems: 'center', + fontSize: '16px', + flexWrap: 'wrap', + '& label': { + flex: '1 0 100%', + fontWeight: 'bolder', + display: 'flex', + alignItems: 'center', + padding: '0.5rem 0', + '@media screen and (min-width: 640px)': { + flex: '0 0 220px', + padding: 0 + } + }, + '& span': { + display: 'block', + flex: '1 1 auto', + marginLeft: 'calc(1rem + 20px)', + '@media screen and (min-width: 640px)': { + marginLeft: 'calc(1rem + 20px)' + }, + '@media screen and (min-width: 1024px)': { + marginLeft: 0 + } + } + } +})); diff --git a/frontend/src/components/ReadySetCyber/dummyData.js b/frontend/src/components/ReadySetCyber/dummyData.js new file mode 100644 index 00000000..c45e624a --- /dev/null +++ b/frontend/src/components/ReadySetCyber/dummyData.js @@ -0,0 +1,191 @@ +const dummyType = ['micro/small', 'small/medium', 'medium/large']; + +const dummyDate = [0o1 / 0o1 / 2021, 0o1 / 0o2 / 2021, 0o1 / 0o3 / 2021]; + +const microCategories = [ + { + id: 1, + name: 'Identity Access Management' + }, + { + id: 2, + name: 'Device Configuration & Security' + }, + { + id: 3, + name: 'Data Security' + } +]; + +const smallToLargeCategories = [ + { + id: 1, + name: 'Identity Access Management' + }, + { + id: 2, + name: 'Device Configuration & Security' + }, + { + id: 3, + name: 'Data Security' + }, + { + id: 4, + name: 'Governance & Training' + }, + { + id: 5, + name: 'Vulnerability Management' + }, + { + id: 6, + name: 'Supply Chain Risk Management' + }, + { + id: 7, + name: 'Incident Response' + } +]; + +const radialButtons = [ + { + id: 1, + name: 'Implemented', + selected: false + }, + { + id: 2, + name: 'In Progress', + selected: false + }, + { + id: 3, + name: 'Scoped', + selected: true + }, + { + id: 4, + name: 'Not in Scope', + selected: false + } +]; + +const dummyQuestions = [ + { + id: 1, + title: + 'Does your organization have security controls in place that appropriately identify, authenticate, and authorize users?', + answers: radialButtons, + category: 'Identity Access Management' + }, + { + id: 2, + title: + 'Does your system require a minimum password length of 15 characters (including OT systems, where technically feasible)?', + answers: radialButtons, + category: 'Identity Access Management' + }, + { + id: 3, + title: + 'Do you log all unsuccessful login attempts and provide security teams with alerts if a certain number of unsuccessful logins occur over a short period of time?', + answers: radialButtons, + category: 'Identity Access Management' + }, + { + id: 4, + title: + 'Do you change default passwords on all your IT and OT assets to the maximum extent possible, and implement compensating security controls wherever it is not?', + answers: radialButtons, + category: 'Identity Access Management' + }, + { + id: 5, + title: + 'Do you have a process in place to ensure that all devices are configured to the most secure settings possible?', + answers: radialButtons, + category: 'Device Configuration & Security' + }, + { + id: 6, + title: + 'Do you have administrative policies or automated processes that ensure all new hardware, firmware, and software are updated with the latest security patches?', + answers: radialButtons, + category: 'Device Configuration & Security' + }, + { + id: 7, + title: + 'Are Microsoft Office macros, or similar embedded code, disabled by default?', + answers: radialButtons, + category: 'Device Configuration & Security' + }, + { + id: 8, + title: + 'Do you maintain a regulary updated inventory of all organizational assets with an IP address, and update this on a regular basis?', + answers: radialButtons, + category: 'Device Configuration & Security' + } +]; +const dummyResults = [ + { + id: 1, + type: 'micro/small', + date: '2021-01-01', + categories: microCategories, + questions: dummyQuestions + }, + { + id: 2, + type: 'small/medium', + date: '2021-01-02', + categories: smallToLargeCategories, + questions: dummyQuestions + }, + { + id: 3, + type: 'medium/large', + date: '2021-01-03', + categories: smallToLargeCategories, + questions: dummyQuestions + }, + { + id: 4, + type: 'micro/small', + date: '2021-01-01', + categories: microCategories, + questions: dummyQuestions + }, + { + id: 5, + type: 'small/medium', + date: '2021-01-02', + categories: smallToLargeCategories, + questions: dummyQuestions + }, + { + id: 6, + type: 'medium/large', + date: '2021-01-03', + categories: smallToLargeCategories, + questions: dummyQuestions + }, + { + id: 7, + type: 'micro/small', + date: '2021-01-01', + categories: microCategories, + questions: dummyQuestions + }, + { + id: 8, + type: 'small/medium', + date: '2021-01-02', + categories: smallToLargeCategories, + questions: dummyQuestions + } +]; + +export { dummyType, dummyDate, smallToLargeCategories, dummyResults }; diff --git a/frontend/src/components/__tests__/header.spec.tsx b/frontend/src/components/__tests__/header.spec.tsx index 7b1189d8..99f9ed13 100644 --- a/frontend/src/components/__tests__/header.spec.tsx +++ b/frontend/src/components/__tests__/header.spec.tsx @@ -66,7 +66,7 @@ describe('Header component', () => { currentOrganization: { ...testOrganization } } }); - ['Overview', 'Inventory', 'Scans'].forEach((expected) => { + ['Overview', 'Inventory'].forEach((expected) => { expect(getByText(expected)).toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/AdminTools/AdminTools.tsx b/frontend/src/pages/AdminTools/AdminTools.tsx new file mode 100644 index 00000000..3b8a2c79 --- /dev/null +++ b/frontend/src/pages/AdminTools/AdminTools.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import classes from 'pages/Scans/Scans.module.scss'; +import ScansView from 'pages/Scans/ScansView'; +import ScanTasksView from 'pages/Scans/ScanTasksView'; +import { Subnav } from 'components'; +import { Switch, Route } from 'react-router-dom'; + +export const AdminTools: React.FC = () => { + return ( + <> + +
+ + + + + + + + +
+ + ); +}; + +export default AdminTools; diff --git a/frontend/src/pages/AdminTools/index.ts b/frontend/src/pages/AdminTools/index.ts new file mode 100644 index 00000000..074285ee --- /dev/null +++ b/frontend/src/pages/AdminTools/index.ts @@ -0,0 +1 @@ +export { default } from './AdminTools'; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index c3392002..a25fa0e6 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -7,6 +7,7 @@ export * from './Vulnerability'; export * from './TermsOfUse'; export * from './Search'; export * from './LoginGovCallback'; +export { default as AdminTools } from './AdminTools'; export { default as Organization } from './Organization'; export { default as Vulnerabilities } from './Vulnerabilities'; export { default as Risk } from './Risk'; diff --git a/playwright/.gitignore b/playwright/.gitignore new file mode 100644 index 00000000..dbcee752 --- /dev/null +++ b/playwright/.gitignore @@ -0,0 +1,6 @@ +/blob-report/ +/playwright-report/ +/playwright/.cache/ +/test-results/ +node_modules/ +storageState.json diff --git a/playwright/e2e/global-admin/home.spec.ts b/playwright/e2e/global-admin/home.spec.ts new file mode 100644 index 00000000..1e1795e2 --- /dev/null +++ b/playwright/e2e/global-admin/home.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, Page } from '@playwright/test'; +import exp from 'constants'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); +test('home', async () => { + // Expect home page to show Latest Vulnerabilities. + await expect( + page.getByRole('heading', { name: 'Latest Vulnerabilities' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Open Vulnerabilities by Domain' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Most Common Ports' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Most Common Vulnerabilities' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Severity Levels' }) + ).toBeVisible(); + await expect( + page.getByPlaceholder('Search a domain, vuln, port, service, IP') + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Inventory' })).toBeVisible(); + await page.screenshot({ path: 'test-results/img/global-admin/home.png' }); +}); + +test('Open Vulnerabilities by Domain', async () => { + await page.getByRole('button', { name: 'All' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_all.png' + }); + if ( + (await page.getByRole('button', { name: 'Medium' }).isDisabled()) == false + ) { + await page.getByRole('button', { name: 'Medium' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_medium.png' + }); + } + if ( + (await page.getByRole('button', { name: 'High' }).isDisabled()) == false + ) { + await page.getByRole('button', { name: 'High' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/open_vuln_high.png' + }); + } + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } +}); diff --git a/playwright/e2e/global-admin/inventory.spec.ts b/playwright/e2e/global-admin/inventory.spec.ts new file mode 100644 index 00000000..559b7679 --- /dev/null +++ b/playwright/e2e/global-admin/inventory.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, chromium, Page } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); +test('Inventory', async () => { + await page.getByRole('link', { name: 'Inventory' }).click(); + await expect(page).toHaveURL('/inventory'); + await page.getByRole('button', { name: 'IP(s)' }).click(); + await page.getByRole('button', { name: 'Severity' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'Domain Name' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'IP' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'Last Seen' }).click(); + await page.getByLabel('Sort by:').first().click(); + await page.getByRole('option', { name: 'First Seen' }).click(); + await page.screenshot({ + path: 'test-results/img/global-admin/inventory.png' + }); +}); + +test('Domains', async () => { + await page.goto('/inventory'); + await page.getByRole('link', { name: 'All Domains' }).click(); + await expect(page).toHaveURL('/inventory/domains'); + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } + await page.screenshot({ path: 'test-results/img/global-admin/domains.png' }); +}); + +test('Domain details', async () => { + await page.goto('/inventory/domains'); + await page.getByRole('row').nth(2).getByRole('link').click(); + await expect(page).toHaveURL(new RegExp('/inventory/domain/')); + await expect(page.getByText('IP:')).toBeVisible(); + await expect(page.getByText('First Seen:')).toBeVisible(); + await expect(page.getByText('Last Seen:')).toBeVisible(); + await expect(page.getByText('Organization:')).toBeVisible(); + await page.screenshot({ + path: 'test-results/img/global-admin/domain_details.png' + }); +}); + +test('Domains filter', async () => { + await page.goto('/inventory/domains'); + await page.locator('#organizationName').click(); + await page.locator('#organizationName').fill('Homeland'); + await page.locator('#organizationName').press('Enter'); + let rowCount = await page.getByRole('row').count(); + for (let it = 2; it < rowCount; it++) { + await expect( + page.getByRole('row').nth(it).getByRole('cell').nth(1) + ).toContainText('Homeland'); + } + await page.screenshot({ + path: 'test-results/img/global-admin/domain_filter.png' + }); +}); diff --git a/playwright/e2e/global-admin/vulnerabilities.spec.ts b/playwright/e2e/global-admin/vulnerabilities.spec.ts new file mode 100644 index 00000000..ca92064f --- /dev/null +++ b/playwright/e2e/global-admin/vulnerabilities.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, chromium, Page } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto('/'); +}); + +test.afterAll(async () => { + await page.close(); +}); + +test('Vulnerabilities', async () => { + await page.getByRole('link', { name: 'Inventory' }).click(); + await page.getByRole('link', { name: 'All Vulnerabilities' }).click(); + await expect(page).toHaveURL('/inventory/vulnerabilities'); + if ((await page.getByLabel('Go to next page').isDisabled()) == false) { + await page.getByLabel('Go to next page').click(); + } + if ((await page.getByLabel('Go to previous page').isDisabled()) == false) { + await page.getByLabel('Go to previous page').click(); + } + await page.screenshot({ + path: 'test-results/img/global-admin/vulnerabilities.png' + }); +}); + +test('Vulnerability details NIST', async () => { + await page.goto('/inventory/vulnerabilities'); + const newTabPromise = page.waitForEvent('popup'); + await page.getByRole('row').nth(2).getByRole('link').nth(0).click(); + const newTab = await newTabPromise; + await newTab.waitForLoadState(); + await expect(newTab).toHaveURL( + new RegExp('^https://nvd\\.nist\\.gov/vuln/detail/') + ); +}); + +test('Domain details link', async () => { + await page.goto('/inventory/vulnerabilities'); + await page.getByRole('row').nth(2).getByRole('link').nth(1).click(); + await expect(page).toHaveURL(new RegExp('/inventory/domain/')); +}); + +test('Vulnerability details', async () => { + await page.goto('/inventory/vulnerabilities'); + await page.getByRole('row').nth(2).getByRole('link').nth(2).click(); + await expect(page).toHaveURL(new RegExp('/inventory/vulnerability/')); + await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Installed (Known) Products' }) + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Provenance' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Vulnerability Detection History' }) + ).toBeVisible(); +}); diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 00000000..410d250a --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,42 @@ +import { chromium, FullConfig, test as setup } from '@playwright/test'; +import * as OTPAuth from 'otpauth'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const authFile = './storageState.json'; + +let totp = new OTPAuth.TOTP({ + issuer: process.env.PW_XFD_2FA_ISSUER, + label: 'Crossfeed', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: process.env.PW_XFD_2FA_SECRET +}); + +async function globalSetup(config: FullConfig) { + const { baseURL, storageState } = config.projects[0].use; + const browser = await chromium.launch(); + const page = await browser.newPage(); + + //Log in with credentials. + await page.goto(String(process.env.PW_XFD_URL)); + await page + .getByPlaceholder('Enter your email address') + .fill(String(process.env.PW_XFD_USERNAME)); + await page + .getByPlaceholder('Enter your password') + .fill(String(process.env.PW_XFD_PASSWORD)); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page + .getByPlaceholder('Enter code from your authenticator app') + .fill(totp.generate()); + await page.getByRole('button', { name: 'Confirm' }).click(); + //Wait for storageState to write to json file for other tests to use. + await page.waitForTimeout(1000); + await page.context().storageState({ path: authFile }); + await page.close(); +} + +export default globalSetup; diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 00000000..39b36fb4 --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,125 @@ +{ + "lockfileVersion": 3, + "name": "xfd_playwright", + "packages": { + "": { + "dependencies": { + "dotenv": "^16.4.5", + "otpauth": "^9.2.2" + }, + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.20" + }, + "license": "ISC", + "name": "xfd_playwright", + "version": "1.0.0" + }, + "node_modules/@playwright/test": { + "bin": { + "playwright": "cli.js" + }, + "dependencies": { + "playwright": "1.41.2" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/@types/node": { + "dependencies": { + "undici-types": "~5.26.4" + }, + "dev": true, + "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", + "version": "20.11.20" + }, + "node_modules/dotenv": { + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + }, + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "version": "16.4.5" + }, + "node_modules/fsevents": { + "dev": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, + "hasInstallScript": true, + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "version": "2.3.2" + }, + "node_modules/jssha": { + "engines": { + "node": "*" + }, + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "version": "3.3.1" + }, + "node_modules/otpauth": { + "dependencies": { + "jssha": "~3.3.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + }, + "integrity": "sha512-2VcnYRUmq1dNckIfySNYP32ITWp1bvTeAEW0BSCR6G3GBf3a5zb9E+ubY62t3Dma9RjoHlvd7QpmzHfJZRkiNg==", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.2.2.tgz", + "version": "9.2.2" + }, + "node_modules/playwright": { + "bin": { + "playwright": "cli.js" + }, + "dependencies": { + "playwright-core": "1.41.2" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", + "optionalDependencies": { + "fsevents": "2.3.2" + }, + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/playwright-core": { + "bin": { + "playwright-core": "cli.js" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", + "version": "1.41.2" + }, + "node_modules/undici-types": { + "dev": true, + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "version": "5.26.5" + } + }, + "requires": true, + "version": "1.0.0" +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 00000000..38e48e97 --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,18 @@ +{ + "author": "", + "dependencies": { + "dotenv": "^16.4.5", + "otpauth": "^9.2.2" + }, + "description": "", + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.20" + }, + "keywords": [], + "license": "ISC", + "main": "index.js", + "name": "xfd_playwright", + "scripts": {}, + "version": "1.0.0" +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 00000000..2781f82f --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +dotenv.config(); + +export default defineConfig({ + globalSetup: './global-setup', + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['list', { printSteps: true }], + ['json', { outputFile: 'test-results/test-results.json' }], + ['html', { open: 'always' }] + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PW_XFD_URL, + storageState: 'storageState.json', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}); diff --git a/playwright/tests-examples/demo-todo-app.spec.ts b/playwright/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 00000000..775ea003 --- /dev/null +++ b/playwright/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,489 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0]]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ + page + }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ + page + }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count'); + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass([ + 'completed', + 'completed', + 'completed' + ]); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ + page + }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ + page + }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue( + TODO_ITEMS[1] + ); + await secondTodo + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect( + todoItem.locator('label', { + hasText: TODO_ITEMS[1] + }) + ).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill(' buy some sausages '); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ + page + }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Enter'); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .fill('buy some sausages'); + await todoItems + .nth(1) + .getByRole('textbox', { name: 'Edit' }) + .press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count'); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect( + page.getByRole('button', { name: 'Clear completed' }) + ).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ + page + }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect( + page.getByRole('button', { name: 'Clear completed' }) + ).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass( + 'selected' + ); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage( + page: Page, + expected: number +) { + return await page.waitForFunction((e) => { + return ( + JSON.parse(localStorage['react-todos']).filter( + (todo: any) => todo.completed + ).length === e + ); + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction((t) => { + return JSON.parse(localStorage['react-todos']) + .map((todo: any) => todo.title) + .includes(t); + }, title); +} diff --git a/playwright/tests/example.spec.ts b/playwright/tests/example.spec.ts new file mode 100644 index 00000000..2a6048e1 --- /dev/null +++ b/playwright/tests/example.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://localhost/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Crossfeed/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect( + page.getByRole('heading', { name: 'Installation' }) + ).toBeVisible(); +});